Agent Skills & Plan Mode Guide
Two BaseAgent-framework capabilities that ship together (they share one migration): Skills — reusable capability bundles an agent can activate mid-run — and Plan Mode — a per-request human-in-the-loop gate that makes an agent get its plan approved before it acts.
Audience: developers building/configuring agents, authoring skills, or wiring skills/plan-mode into a chat surface.
Both are agent-framework features, not chat-widget features. They work for any agent invocation — the conversations UI, headless/scheduled runs, the API — because the logic lives in BaseAgent / AIEngineBase, and the chat surface is just one caller.
Part 1 — Skills
Section titled “Part 1 — Skills”1.1 The mental model
Section titled “1.1 The mental model”A Skill (MJ: AI Skills) is a bundle of three things:
| Piece | Column / table | Effect when the skill activates |
|---|---|---|
| Instructions | AISkill.Instructions (NVARCHAR MAX) | Appended to the agent’s context for the rest of the run |
| Actions | MJ: AI Skill Actions (junction → Action) | Added to the agent’s run — described to the model and executable — when the row’s ExposeToModel is set (the default). With it off, the action stays bundled for SKILL.md export and tooling but is left out of the run entirely; application code invokes it through the Actions API (see §1.3) |
| Sub-agents | MJ: AI Skill Sub Agents (junction → AIAgent) | Added to the agent’s available sub-agents |
The point is write-once, grant-to-many: instead of copy-pasting the same instruction block + action set into every agent’s system prompt, you author it once as a Skill and grant it to any agent that should have it. Skills are also shareable and portable (see §1.6, §1.7).
Activation is progressive-disclosure: an agent only ever sees a skill’s name + description in its prompt catalog. It sees the full Instructions only after it chooses to activate the skill — so a large library of skills costs very few prompt tokens until one is actually used.
Activation is not a nested agent run. It’s an in-loop step that appends instructions and widens the tool surface, then continues the same run.
1.2 The three-layer gate
Section titled “1.2 The three-layer gate”Whether a given agent may activate a given skill is resolved by AIEngineBase.GetSkillsForAgent(agent) (packages/AI/BaseAIEngine/src/BaseAIEngine.ts) through three independent gates — all must pass:
include skill ⟺ AIAgent.AcceptsSkills ≠ 'None' (per-agent gate) AND AISkill.Status = 'Active' (catalog gate) AND (AcceptsSkills = 'All' OR an Active MJ: AI Agent Skills grant exists) (grant gate)AIAgent.AcceptsSkills—None(default; opt-in, so existing agents are unaffected) ·All(any Active skill) ·Limited(only skills granted viaMJ: AI Agent Skills).AISkill.Status— onlyActiveskills are activatable.Deprecatedretires a skill without deleting it (defaults toActiveso a freshly authored skill works immediately for its owner).MJ: AI Agent Skills.Status— per-grantActive/Pending/Revoked, so a grant can be disabled without unlinking (only consulted whenAcceptsSkills = 'Limited').
At runtime a fourth, orthogonal gate also applies: an activated skill’s bundled Actions are still subject to Action.Status, and bundled sub-agents to AIAgent.Status — a deprecated Action never flows through any skill.
Action/Agent bundle IDs are read via GetSkillActionIDs(skillID) / GetSkillSubAgentIDs(skillID); the engine returns IDs only and lets callers resolve the full entities against their own ActionEngineServer / AIEngine caches (no cross-package dependency).
1.2a The double activation gate (v5.45) — availability vs. trigger
Section titled “1.2a The double activation gate (v5.45) — availability vs. trigger”The §1.2 gates answer “may this agent+user use this skill at all?” (availability). A second, orthogonal dimension answers “who may pull the trigger?” (activation), added after live testing showed an AcceptsSkills='All' agent correctly — but surprisingly — self-activating a skill the user never requested:
| Column | Values | Default | Governs |
|---|---|---|---|
AISkill.ActivationMode | Auto / RequestedOnly | RequestedOnly | May this skill ever be self-activated? |
AIAgent.SkillActivationMode | Auto / RequestedOnly | RequestedOnly | May this agent ever self-activate skills? |
Self-activation (the skill appearing in the agent’s prompt catalog + an agent-initiated Skill step) requires Auto on BOTH sides — resolved by AIEngineBase.GetAutoActivatableSkillsForAgent(agent, user?), which is GetSkillsForAgent(...) further filtered by the two dials. The catalog injection, validateSkillNextStep, and resolveSkillActivations all use this auto set.
The requested path is NOT gated by ActivationMode. A user’s explicit /skill mention (→ ExecuteAgentParams.requestedSkillIDs → preActivateRequestedSkills) honors a RequestedOnly skill — that’s the mode’s whole point — subject to all §1.2 availability gates.
Because both defaults are RequestedOnly, the Auto × Auto “super agent” posture (an agent that expands its own tool surface at runtime) always requires two deliberate opt-ins and can never arise accidentally — the defense against skill leakage into an agent. Configuration recipes:
- Research assistant that should reach for web/search/visualization skills on its own judgment: agent
SkillActivationMode='Auto'+ those skillsActivationMode='Auto'. - Consequential skill (e.g. Communications — it sends things): keep
ActivationMode='RequestedOnly'. No agent, regardless of posture, can self-activate it; users must/skillit in explicitly, and the skill’s own confirm-before-send instructions still gate the act. - Locked-down agent:
SkillActivationMode='RequestedOnly'— its prompt catalog is empty; skills only enter its runs when the user asks.
1.2b Layering an application policy — filterAvailableSkills
Section titled “1.2b Layering an application policy — filterAvailableSkills”MJ’s gates are User/Role and agent based; they have no notion of a tenant, a licence or an entitlement. An application that needs one overrides one protected hook:
protected override async filterAvailableSkills( skills: MJAISkillEntity[], purpose: SkillAvailabilityPurpose, agent: MJAIAgentEntityExtended, contextUser?: UserInfo,): Promise<MJAISkillEntity[]> { const usable = await myLicensing.usableSkillIDs(contextUser); // cached — this runs every prompt turn return skills.filter(s => usable.has(s.ID)); // subset only; on error return []}BaseAgent calls it after its own gates and before anything activates, at all three availability
sites: the prompt catalog (purpose: 'catalog'), a model-initiated Skill step’s validation and
execution ('auto-activation'), and a user’s explicit request ('requested'). So a skill the policy
refuses is never offered, never self-activated, and refused (with the usual system note) when asked
for. The default is the identity: without an override, MJ’s gates are the whole policy.
Two notes for override authors (and one on logs: a policy that throws persistently logs that error on every prompt turn via the catalog site — that is the fail-closed behaviour working, and the pattern to recognise). It is called at four sites (catalog on every prompt turn, Skill-step
validation, Skill-step execution, requested pre-activation), so cache your lookups. And it is
server-side: the composer’s /skill warning (conversation-agent.service.ts) checks only MJ’s own
GetSkillsForAgent, so a client can still send a request the policy then refuses — it surfaces as
the refusal note, not as a picker omission.
1.3 The runtime flow (Loop agent)
Section titled “1.3 The runtime flow (Loop agent)”gatherPromptTemplateData() ── injects skill CATALOG (name+description only) into the prompt │ ▼LLM emits nextStep.type='Skill', skills:[{name}] │ ▼LoopAgentType.DetermineNextStep ── → BaseAgentNextStep.step='Skill', skillActivations:[{name}] │ ▼validateSkillNextStep ── fuzzy-matches names vs GetSkillsForAgent; unknown/disallowed → Retry │ ▼executeSkillStep ── for each newly-activated skill: ├─ recordSkillActivationStep() → AIAgentRunStep (StepType='Skill') ├─ buildSkillActivationMessage() → appends Instructions to conversationMessages └─ enableSkillCapabilities() → pushes 'specific'-scoped add ActionChange/SubAgentChange (Actions: the skill's ExposeToModel rows only — GetSkillExposedActionIDs) │ ▼executePromptStep ── loop continues; next turn's gatherPromptTemplateData picks up the widened tool surfaceKey implementation notes (all in base-agent.ts):
'Skill'is a non-terminal step. Like'ClientTools', it is deliberately not part of the generatedAIAgentRun.FinalStepunion (that column is DB-CHECK-constrained to terminal outcomes).BaseAgentNextStep.stepis typed offFinalStep, so the switch sites use an explicit'Skill' as typeof …stepcast. It is in theAIAgentRunStep.StepTypeCHECK — that column records what executed, a different concern from a run’s final outcome.ExposeToModeldecides whether a bundled action joins the run at all (v6.1.x, #4226).enableSkillCapabilitiespushesGetSkillExposedActionIDs(skill.ID)— the rows with the flag set (default) — ontoparams.actionChanges. The run’s effective action set is one list that is both the prompt’s tool surface and the execution allow-list, so a row with the flag off is not described to the model and cannot be executed by the agent through any path (Actionsstep, aPreProcessActionStep, a loop’s action lookup all resolve against that set and reject it). That is deliberate: hiding it from the prompt alone would still let the model call it by name the moment a skill’s Instructions mention it. The row keeps the action associated with the skill for SKILL.md export and tooling; application code — a menu button in the skill’s reply — invokes it directly through the Actions API, whose authorization is its own (bundling grants nothing there). Because the agent never runs such an action, skill attribution on the Actions step does not apply to it.- Tool-surface widening uses
scope: 'specific'targeting the activating agent’s own ID (agentIds: [agent.ID]), pushed ontoparams.actionChanges/params.subAgentChanges. This applies to the activating agent at any depth (a sub-agent that activates a skill still gets its tools — a'root'scope would only apply at depth 0) and never cascades to that agent’s own sub-agents (viafilterActionChangesForSubAgent). Becauseparamsis the same object for the whole run, every later turn’sgatherPromptTemplateData()re-applies it automatically. - Idempotent re-activation.
_activatedSkillIDstracks what’s already active; re-requesting an active skill is a harmless no-op (no duplicate instructions, no duplicate change entries). _effectiveSubAgentsmirrors_effectiveActionsso a runtime-added sub-agent (from a skill) validates correctly invalidateSubAgentNextStep. (This closed a pre-existing gap in thesubAgentChangesmechanism that affected any consumer, not just Skills.)
executeSkillStep is decomposed into resolveSkillActivations / buildSkillActivationMessage / enableSkillCapabilities / recordSkillActivationStep, all protected — override any one for fine-grained control (custom instruction formatting, a licensing check on activation, cascading grants via 'all-subagents', etc.) without re-implementing the step.
1.3a Observability — AIAgentRunStep.Skills (v5.45)
Section titled “1.3a Observability — AIAgentRunStep.Skills (v5.45)”Every step touched by a skill records the linkage on AIAgentRunStep.Skills — a JSON-typed column (Array<AgentSkillInvocation> | null, generated accessor SkillsObject) so skill involvement is auditable per step, not inferred:
type AgentSkillInvocation = { SkillID: string; SkillName: string; // identity as of activation ActivationType: 'requested' | 'auto'; // /skill mention vs. agent self-activation Provenance: { // the gate values that admitted it AgentAcceptsSkills: string; SkillActivationMode: string; AgentSkillActivationMode: string; RequestedBy: 'user-request' | 'agent-decision'; }; Reason?: string; // agent-stated rationale (auto only)}Population rules (all in createStepEntity + the activation paths):
| Step type | Skills contains |
|---|---|
| Skill | The activation(s) that step performed — with Reason when the model self-activated (the loop response’s skills:[{name, reason}] now carries an optional one-sentence rationale) |
| Prompt | The full set of skills in effect for that turn — prompt injection is never invisible |
| Actions / Sub-Agent | The skill(s) through which the executed tool became available. NULL means the tool was a native grant — native authority takes precedence over skill attribution (getSkillAttributionForAction / getSkillAttributionForSubAgent) |
| anything else | NULL |
The runtime type twin is AgentSkillInvocation in @memberjunction/ai-core-plus; the CodeGen JSON-type interface lives at metadata/entities/JSONType-interfaces/AgentSkillInvocation.ts — keep them in sync.
UX: the agent-run form renders this end to end — Skill/Plan step nodes get dedicated icons, any step with Skills shows per-skill chips (auto-activations get a warning accent — the agent expanded its own surface), and the step drill-in panel gains a Skills tab showing each invocation’s activation type, provenance-of-authority grid, and reason.
1.4 Realtime and proxy agents
Section titled “1.4 Realtime and proxy agents”- Realtime agents don’t run the Loop; a skill’s instructions are appended at session build (session-static), not activated in-loop.
- Proxy agents delegate their whole loop to a remote system; skills are the remote’s concern.
1.5 Prompt template
Section titled “1.5 Prompt template”The Loop system prompt template (metadata/prompts/templates/system/loop-agent-type-system-prompt.template.md) gates the entire Skills section on {% if skillCount > 0 %} — agents with no available skills see nothing about skills, keeping their prompt unchanged. skillCount / skillsCatalog come from AgentContextData (populated in gatherPromptTemplateData).
1.6 Permissions: full agent-parity, open-by-default
Section titled “1.6 Permissions: full agent-parity, open-by-default”Skills use the same dedicated-table permission model as AI Agents — MJ: AI Skill Permissions (AISkillPermission) is the exact sibling of AIAgentPermission: a row grants a User (UserID) or a Role (RoleID) — never both, never neither (a server-side validator enforces this; see below) — one of CanView / CanRun / CanEdit / CanDelete. A skill’s owner is its CreatedByUserID.
There are two access paths over the same table, with different defaults and purposes (the general pattern is documented in the Unified Permissions Guide):
| Path | Class | Default (no rows) | Used for |
|---|---|---|---|
| Runtime helper | AISkillPermissionHelper (@memberjunction/ai-engine-base) | Open — anyone can View + Run; only owner Edits/Deletes | The hot path: the /skill picker filter and the server-side run-time guard |
| Unified provider | AISkillPermissionProvider (@memberjunction/core-entities, @RegisterClass(PermissionProviderBase, 'MJAISkillPermissionProvider'), domain AI Skill Permissions) | Closed — explicit grants only | Sharing Center / audit via the PermissionEngine |
Action mapping (both paths): CanView → Read, CanRun → Execute, CanEdit → Update, CanDelete → Delete. The helper applies the hierarchy Delete → Edit → Run → View and is a synchronous cache hit because AIEngineBase caches SkillPermissions alongside Skills.
- Grantee-exclusivity validator:
MJAISkillPermissionEntityServer(@memberjunction/core-entities-server) overridesValidate()to reject a row with both or neither grantee — the deterministic, version-controlled equivalent of the LLM-generatedValidateRoleIDAndUserIDExclusiveonAIAgentPermission. It fires on every server save path, so the sharing UI, Remote Operations, and scripts are all covered. - The permission filter:
AIEngineBase.GetSkillsForAgent(agent, user?)takes an optional user. Without it, you get pure agent-gating (§1.2). With it, the result is additionally intersected with the user’s Run permission — so the same call is the single source of truth for “what may this agent activate for this user”.BaseAgentpassesparams.contextUser, so the skills catalog the model sees is already permission-filtered — an agent is never even offered a skill the acting user can’t run.
Sharing is gated behind the dedicated Can Share Skills authorization (MJ: Authorizations); authoring/using your own skills never requires it. In the UI, the generated MJ: AI Skills form gets a Manage Permissions grid + Export/Import SKILL.md from the AISkillSharingPanel (a BaseFormPanel slot component), and the permission grid itself is a skill-scoped mirror of the agent permissions grid (SkillPermissionsPanel/SkillPermissionsDialog in @memberjunction/ng-agents). The generated form’s related-entity grid also edits MJ: AI Skill Permissions directly. (The earlier AI Skills Resource Type / MJ: Resource Permissions sharing was retired — skills now own their permission table, exactly like agents.)
1.6a /skill invocation — user-requested pre-activation
Section titled “1.6a /skill invocation — user-requested pre-activation”A user can activate a skill for a message by typing /skill-name in the conversation composer — the exact sibling of @agent and #entity mentions (the mention system’s MENTION_TRIGGERS now includes /). The picker (MentionAutocompleteService) lists only Active skills the user has Run permission for (filtered through AISkillPermissionHelper, open-by-default), and each chip renders with the skill’s own IconClass + Color UX metadata. Selected skill IDs are collected from the composer and threaded as ExecuteAgentParams.requestedSkillIDs (a string[]) down the same client→resolver→runtime chain as planMode:
/skill chips → message-input.component (collectRequestedSkillIDs) → conversation-agent.service.invokeSubAgent(requestedSkillIDs) [or ConversationAgentRunner.ProcessMessage] → RunAgentFromConversationDetailParams.RequestedSkillIDs → GraphQL $requestedSkillIDs:[String!] → RunAIAgentResolver → ExecuteAgentParams.requestedSkillIDs → BaseAgent.preActivateRequestedSkills() ← guarded pre-activation at run startpreActivateRequestedSkills runs once at run start (root agent only), and activates each requested skill only if it survives the guard: it must be in GetSkillsForAgent(agent, contextUser) — i.e. the agent accepts it and the user may run it. Anything else is silently dropped, so a client can never smuggle in a skill the user or agent isn’t entitled to. Surviving skills are activated through the same recordSkillActivationStep / enableSkillCapabilities / buildSkillActivationMessage machinery as a model-initiated Skill step, so their Instructions + bundled tools take effect from turn 1. Plan Mode is unaffected — pre-activation widens the tool surface, but the plan-approval gate still blocks executing those tools until approved.
1.6b Skills and Scoped Search — the principal comes from the run
Section titled “1.6b Skills and Scoped Search — the principal comes from the run”Scoped Search takes an optional AISkillID input: the skill on whose behalf the search runs. The
skill is a principal — its SearchScopeAccess / MJ: AI Skill Search Scopes can deny or widen a
scope, and ScopeDimensionResolver binds it as Principals.SkillID into a dimension’s expansion
query (the mechanism behind “invoking this skill changes the content”). Inside a Loop agent that
input is written by the model, so it cannot be the authority on which skill is active.
BaseAgent.ExecuteSingleAction therefore stamps Context.ActiveSkillIDs — the skills the run has
activated so far — onto every action call. Scoped Search reads it:
| inside a run | outcome |
|---|---|
no AISkillID named, exactly one skill active | that skill is the principal |
no AISkillID named, several active | no principal (verbose log) — the caller must say which |
AISkillID named and active in the run | accepted (the loaded skill’s id is threaded) |
AISkillID named but NOT active in the run | INVALID_PARAM — the model may not widen its own reach |
Outside a run (no ActiveSkillIDs on the context) the explicit input is the only source, unchanged.
An app that gates activation with its own policy reads the same state through the protected
ActivatedSkillIDs getter on BaseAgent rather than re-deriving it.
1.6c Conversation-scoped skills — a mode that survives the turn
Section titled “1.6c Conversation-scoped skills — a mode that survives the turn”By default a skill is active for the run that activated it (AISkill.ActivationScope = 'Run'). A
skill that behaves as a mode — a persona, an assistant whose reply carries a menu pressed on the
next turn — sets ActivationScope = 'Conversation'. Then:
- when it activates in a run that has a
conversationId(requested or self-activated),BaseAgentwrites anMJ: Conversation Skillsrow (Status = 'Active',ActivatedByRunID= the run); - at the start of every later root run in that conversation, Active rows join
requestedSkillIDsbefore the gates — so the skill is re-activated with its Instructions and tools, subject to every availability gate on that run; - a persisted skill a gate refuses this turn is skipped without a note (the user did not mention it) and its row stays Active — retiring a mode is explicit, never a side effect of a gate miss;
- the app’s “leave the mode” gesture calls
BaseAgent.EndConversationSkill(conversationId, skillId, user); a later activation re-uses the row.
Each step is a protected hook (loadConversationSkillIDs, persistConversationSkillActivation) and
fails soft. Precedent: UserRoutine.RequestedSkillIDs, the same idea keyed on a routine.
1.7 SKILL.md — portable import/export
Section titled “1.7 SKILL.md — portable import/export”A skill can be exported to / imported from a portable SKILL.md file, so skills move across MJ instances:
---name: Report Builderdescription: Generates formatted business reports from query resultscategory: Reportingactions: - Run Query - Generate PDFcodeOnlyActions: - Generate PDFsubAgents: - Report Formatter Agent---
Instructions body — plain markdown, appended to an accepting agent's systemprompt when the skill is activated.codeOnlyActions (optional) names the actions bundled with ExposeToModel = 0 — see §1.3. Absent key: no
opinion, surviving rows keep their flag. Present but empty (codeOnlyActions: with no items, or []): nothing is
code-only — delete the last name and keep the key to put that action back into the run.
Why names, not IDs: Action/sub-agent references in the frontmatter are names, because names are the only stable cross-instance reference. On import, names are resolved against the target instance’s catalog; anything that doesn’t resolve becomes a non-fatal warning (the skill still imports with whatever did resolve) — a skill authored elsewhere may reference actions this instance doesn’t have.
Two layers, in @memberjunction/ai-agents:
SkillMarkdownConverter— pure, dependency-freeParse/Serialize. Fully unit-tested in isolation. Hand-rolled parser (the frontmatter shape is small and fixed) rather than a YAML dependency.SkillImportExportService— orchestrates against the DB:Config()s the AI + Action engines (idempotent) for name↔ID resolution, creates/updates theMJ: AI Skillsrow, and resyncs the two junction sets.AISkillAction.ExposeToModelround-trips through the optionalcodeOnlyActionsfrontmatter list (names, a subset ofactions): export writes it only when the skill has rows with the flag off, so older files are byte-identical; import applies it when present. A file without the key expresses no opinion, and the resync — which is delete-and-recreate — then carries each surviving row’s flag across, so re-saving a skill’s SKILL.md never turns a code-only action model-callable; only a genuinely new row takes the column default.
Both are exposed as typed, provider-routed Remote Operations — AISkill.ExportMarkdown / AISkill.ImportMarkdown (see REMOTE_OPERATIONS_GUIDE) — so the browser calls new AISkillExportMarkdownOperation().Execute(input, { provider }) with no bespoke resolver/GraphQL client.
Part 2 — Plan Mode
Section titled “Part 2 — Plan Mode”2.1 The mental model
Section titled “2.1 The mental model”Plan Mode makes an agent present a plan and get human approval before it executes any Actions or Sub-Agents. It’s a per-request opt-in built on the existing MJ: AI Agent Requests human-in-the-loop (HITL) pause/resume infrastructure — it adds no new persistence mechanism.
Three switches, resolved once per run in BaseAgent.resolvePlanModeGate:
| Switch | Column / param | Default | Meaning |
|---|---|---|---|
| Capability | AIAgent.SupportsPlanMode (BIT) | 1 (ON, opt-out) | Whether this agent can use plan mode at all |
| Per-request | ExecuteAgentParams.planMode | undefined (OFF) | Whether this run should be gated |
| Mandate (v5.45) | AIAgent.RequirePlanMode (BIT) | 0 (OFF) | Forces plan mode on every root run of this agent, regardless of the per-request flag. SupportsPlanMode is irrelevant when set. |
Plan Mode is active when the agent is the root (depth 0) AND (RequirePlanMode OR (SupportsPlanMode AND planMode === true)). Because both the per-request toggle and the mandate default OFF, default runtime behavior is unchanged — nothing is gated unless a caller opts in or an admin mandates it. Set RequirePlanMode on high-consequence agents (anything with outbound-communication capabilities) where human review must not depend on the caller remembering a toggle. Realtime agents are seeded SupportsPlanMode = 0 (they skip plan mode structurally); Remote Proxy agents will be too when that type ships.
Every plan-mode run is stamped AIAgentRun.PlanMode = 1 (v5.45) — the run-header UX renders a “Plan Mode” chip from it, and it’s the anchor for future plan-drift audits (approved plan vs. steps that actually executed).
2.2 The gate
Section titled “2.2 The gate”While Plan Mode is active and not yet approved, validateNextStep demotes any Actions or Sub-Agent next step to Retry with an explanatory message — the agent literally cannot execute until it presents a plan. Everything else stays allowed: Chat (ask a clarifying question first), Skill (load a skill’s instructions before planning), ForEach/While, ClientTools, Retry.
After approval, skill activations are blocked (v5.45): a post-approval agent-initiated Skill step is demoted to Retry directing the agent to present an updated plan — otherwise the agent could widen its tool surface beyond what the human reviewed. The rule’s shape: activations are legal only before approval (pre-activated /skill requests land before planning; pre-approval self-activations are visible in the plan), so the reviewed plan always reflects the full capability set.
The prompt template surfaces a “Plan Mode — REQUIRED before you may act” section, gated on {% if planModeActive and not planApproved %}.
2.3 The HITL flow
Section titled “2.3 The HITL flow”Plan Mode active, unapproved │ ▼LLM emits nextStep.type='Plan', plan:'…' │ ▼executePlanStep: ├─ records an AIAgentRunStep (StepType='Plan') ← the UI reads this to render a plan-approval card ├─ createFeedbackRequest(...) with an editable ← reuses the EXISTING MJ: AI Agent Requests flow │ plan-approval AgentResponseForm (textarea + Approve/Reject buttongroup) └─ terminates the run (returns a 'Chat'-shaped terminal step) ← awaits the human │ ▼human Approves / edits / Rejects → MJAIAgentRequestEntityServer.Save() auto-resumes (lastRunId set) │ ▼resolvePlanModeGate on the RESUMED run: ├─ re-enables planMode (because the request originated from a 'Plan' step) └─ reads the request's status: Approved/Responded → approved (proceed) · Rejected → unapproved (re-plan)Two subtleties worth calling out (both are load-bearing correctness points):
- The step returned to the framework is
'Chat', not'Plan'.'Plan'is only an intermediate classification and theAIAgentRunStep.StepTypeaudit value the UI keys on. The terminal step must stay withinAIAgentRun.FinalStep’s DB-CHECK-constrained vocabulary, which deliberately excludesPlan/Skill/ClientTools(same reason as §1.3). So a plan-approval pause is represented with the same terminal shapeexecuteChatStepuses. - Rejection forces a re-plan because
resumeAgentre-enablesplanModeonly when the resume originated from aPlanrun-step. Without that, the gate would be off on the resumed run and a rejected plan would silently execute anyway. Regular Chat-clarification resumes are unaffected — they leaveplanModeoff.
resolvePlanModeGate, validatePlanNextStep, executePlanStep, and buildPlanApprovalForm are all protected — override to change eligibility rules, plan-quality validation, or the approval card’s layout.
3. Schema at a glance
Section titled “3. Schema at a glance”v5.44 — Migration V202606301200__v5.44.x__Agent_Skills_And_Plan_Mode.sql. New tables: AISkill, AISkillAction, AISkillSubAgent, AIAgentSkill, AISkillPermission (User xor Role grantee + CanView/CanRun/CanEdit/CanDelete). Additive columns: AIAgent.SupportsPlanMode (default 1), AIAgent.AcceptsSkills (default 'None'), AISkill.IconClass + AISkill.Color (UX metadata for the /skill picker). AIAgentRunStep.StepType CHECK extended with 'Plan' and 'Skill'. Composition junctions (AISkillAction/AISkillSubAgent) are intentionally status-less — member lifecycle is governed by Action.Status/AIAgent.Status; Status lives only on the grant (AIAgentSkill) and the catalog (AISkill). The MJ: Permission Domains catalog row for AI Skill Permissions → MJAISkillPermissionProvider is seeded via metadata sync (metadata/permission-domains/), not the migration.
v5.45 — Migration V202607020314__v5.45.x__AISkill_ActivationMode.sql. Additive columns: AISkill.ActivationMode + AIAgent.SkillActivationMode (both 'Auto'/'RequestedOnly', default 'RequestedOnly' — the double activation gate, §1.2a), AIAgent.RequirePlanMode (BIT, default 0 — mandatory plan mode, §2.1), AIAgentRun.PlanMode (BIT, default 0 — run-level stamp), AIAgentRunStep.Skills (NVARCHAR MAX, JSON-typed Array<AgentSkillInvocation> — per-step observability, §1.3a). The Skills JSON-type binding is seeded via metadata sync (metadata/entities/.entity-field-jsontype-agent-run-step-skills.json + JSONType-interfaces/AgentSkillInvocation.ts).
v6.1.x — Migration V202609111449__v6.1.x__Skill_Action_Expose_To_Model.sql.
Additive: AISkillAction.ExposeToModel (BIT NOT NULL, default 1). 1 = today’s behaviour; 0 keeps the
action bundled (SKILL.md export, tooling) but out of the agent’s run — not described to the model, not
executable by the agent. See §1.3; SKILL.md carries it as codeOnlyActions (§1.7).
v6.1.x — Migration V202609031400__v6.1.x__Conversation_Scoped_Skill_Activation.sql.
Additive: AISkill.ActivationScope ('Run'/'Conversation', default 'Run') and the
ConversationSkill table (MJ: Conversation Skills: ConversationID, SkillID, Status
Active/Ended, ActivatedByRunID, EndedAt; UNIQUE per conversation+skill). See §1.6c.
4. Where to look
Section titled “4. Where to look”| Concern | File |
|---|---|
| Skill resolution + gate + user filter | packages/AI/BaseAIEngine/src/BaseAIEngine.ts (GetSkillsForAgent(agent, user?), GetAutoActivatableSkillsForAgent(agent, user?) — the double gate, SkillPermissions, GetSkillActionIDs/…SubAgentIDs) |
| Skill observability (provenance capture + per-step attribution) | packages/AI/Agents/src/base-agent.ts (buildSkillInvocation, getSkillAttributionForAction/…ForSubAgent, createStepEntity skills param); type in packages/AI/CorePlus/src/agent-types.ts (AgentSkillInvocation) |
| Skill observability UX (run header chip, step chips, Skills tab) | packages/Angular/Explorer/core-entity-forms/src/lib/custom/ai-agent-run/ (ai-agent-run.component.*, ai-agent-run-step-node.component.*, ai-agent-run-step-detail.component.*) |
| Skill permission runtime helper | packages/AI/BaseAIEngine/src/AISkillPermissionHelper.ts (open-by-default, cached) |
| Skill permission unified provider | packages/MJCoreEntities/src/custom/PermissionProviders/AISkillPermissionProvider.ts (+ index.ts LoadPermissionProviders) |
| Grantee-exclusivity validator | packages/MJCoreEntitiesServer/src/custom/MJAISkillPermissionEntityServer.server.ts |
| Skill step + Plan Mode runtime + pre-activation | packages/AI/Agents/src/base-agent.ts (executeSkillStep family, preActivateRequestedSkills, resolvePlanModeGate, executePlanStep); ExecuteSingleAction stamps Context.ActiveSkillIDs |
| Skill/Plan next-step parsing | packages/AI/Agents/src/agent-types/loop-agent-type.ts, loop-agent-response-type.ts |
| Step-type union + params | packages/AI/CorePlus/src/agent-types.ts (BaseAgentNextStep, AgentSkillActivationRequest, ExecuteAgentParams.planMode / .requestedSkillIDs) |
/skill composer UX | packages/Angular/Generic/conversations/src/lib/services/mention-autocomplete.service.ts (getSuggestions(…, '/', targetAgentId) → skillsForTarget → skill-picker-narrowing.ts IntersectAcceptedSkills; the host binds TargetAgentId on mj-ai-composer — mj-message-input binds pickerTargetAgentId: an @agent chip in the draft, else its resolved agent), components/mention/mention-editor.component.ts, components/message/message-input.component.ts |
requestedSkillIDs transport | AgentsClient/src/generic/AgentClientTypes.ts + AgentClientSession.ts, GraphQLDataProvider/src/graphQLAIClient.ts, MJServer/src/resolvers/RunAIAgentResolver.ts, ConversationsRuntime/src/agent-runner/ConversationAgentRunner.ts |
| SKILL.md | packages/AI/Agents/src/SkillMarkdownConverter.ts, SkillImportExportService.ts, operations/AISkillMarkdownOperations.ts |
| Plan-approval resume | packages/AI/Agents/src/MJAIAgentRequestEntityServer.ts |
| Sharing / permission grid / import UI | packages/Angular/Explorer/core-entity-forms/src/lib/panels/ai-skill-sharing/, packages/Angular/Generic/agents/src/lib/{services/skill-permissions.service,components/skill-permissions-*} |
| Governance metadata | metadata/authorizations/ (Can Share Skills), metadata/permission-domains/ (AI Skill Permissions) |
| Tests | unit: packages/AI/Agents/src/__tests__/{skill-step,plan-mode-gate,loop-agent-type,SkillMarkdownConverter}.test.ts, packages/AI/BaseAIEngine/src/__tests__/AISkillPermissionHelper.test.ts, packages/MJCoreEntities/src/__tests__/PermissionProviders/AISkillPermissionProvider.test.ts, packages/MJCoreEntitiesServer/src/__tests__/MJAISkillPermissionEntityServer.test.ts; integration: packages/MJServer/integration-test-scripts/ai-skills-tests.ts |