Skip to content
Documentation GitHub
Agent

Skill Composer

Status: Design landing Reference epics: INK-835, INK-829 ADRs: ADR-016

The Skill Composer is the subagent that turns a skill package plus a concrete invocation request into a prompt and a subgraph shape the main graph can run. It is the only mechanism by which a skill becomes a runnable turn. There is no compiled artifact, no pre-rendered prompt cache, no separately trained module.

This page defines the Composer’s algorithm and its I/O contract. The surrounding framing — what a skill is, why it is not world content, where it lives — is in Skill System. The entity shape, schema, and sync are in Skill Storage.

The Composer is a LangGraph subagent in the Python sidecar (see Agent Core System). It is dispatched from the main agent graph using the same mechanism as any other subagent dispatch — a subgraph call on the same thread_id, sharing state, memory, and the checkpointer with the parent turn.

The Composer is authored — its system prompt and control flow live in the sidecar as ordinary code. The Composer does not compose itself; it is not recursively produced from a skill package.

The retired Rust-side “skill composer” — a ProcessType::SkillComposer process in the agent-harness — is not part of this design. The process model is gone with the LangGraph migration; the Composer concept survives in the sidecar.

  • Not a compiler. It produces an ephemeral plan for one invocation, not a durable artifact.
  • Not a separate runtime. It runs inside the sidecar’s LangGraph graph like any other subagent.
  • Not a tool router. The main graph’s planner decides which skill to apply; the Composer takes that decision as input.
  • Not a permission arbiter. The Composer is bounded by the caller’s capability set; it does not grant or check permissions itself. Permissions and enable/disable state are gated by the planner before dispatch (see Skill Storage).
  • Not stateful across invocations. Each Composer call is independent. Previous composed subgraphs are not retained and not consulted.

The main graph dispatches the Composer with a request object. The shape (informal schema):

ComposeRequest {
skill_package: SkillPackage, // the skill to compose for
user_request: String, // verbatim user text / scheduled-task prompt
invocation_source: InvocationSource,// explicit | agent-selected | scheduled
channel_id: Option<ChannelId>, // None for workspace-scope invocations
thread_id: ThreadId, // shared with parent turn
memory_context: MemoryContext, // prefetched relevant items from the four tiers
available_tools: Vec<ToolDescriptor>,// intersection of skill.tool_surface and caller capabilities
caller_capabilities: CapabilitySet, // authoritative; the Composer reads, does not widen
parameters: Map<String, Value>, // invocation parameters matched against skill's parameter schema
}

A few properties are worth naming explicitly.

  • memory_context is prefetched, not a handle. The parent turn’s context node has already assembled the four-tier reads (see Agent Memory System). The Composer receives the selected items, not a memory API. It may mark which items its produced prompt depends on — the main graph uses that list when replaying the thread.
  • available_tools is already intersected. The caller’s capability resolver has already narrowed skill.tool_surface against the caller’s capabilities. The Composer may not reference tools outside available_tools in its output.
  • parameters is typed by the skill. The parent graph has already validated the invocation parameters against the skill package’s parameter schema (declared in metadata). Missing or mismatched parameters surface as a dispatch error before the Composer is called.
  • channel_id being None means workspace-scope. The Composer does not derive scope; it reads it.

The Composer returns a composition result:

Composition {
prompt: ComposedPrompt, // concrete prompt for the turn
subgraph: SubgraphShape, // tool availability, sequencing hints, interrupt points, exit condition
used_artifacts: Vec<ArtifactRef>, // which skill artifacts the prompt includes
expected_tool_calls: Vec<ToolName>, // best-effort sequence hint (not enforced)
declared_interrupts: Vec<InterruptName>, // named interrupt points the subgraph may fire
exit_condition: ExitCondition, // what "done" looks like for this invocation
model_preference: Option<ModelVariant>, // carried through from skill metadata
}
  • prompt is the concrete prompt text. Framing rendered from the skill’s framing field, augmented with any Description artifacts the Composer decided to include, followed by Example artifacts rendered as few-shots when present, followed by the current user request framed by parameters.
  • subgraph.tool_availability is available_tools or a subset. The Composer may narrow further (e.g., “this invocation does not need the write tool”) but cannot expand.
  • declared_interrupts are named interrupt points the skill’s contract declares — places where the subgraph may pause for author input (see Agent Core System for the interrupt mechanism). The Composer translates skill-contract interrupt names into concrete graph-node references.
  • exit_condition is one of: produced output_schema-matching output, tool-call budget exhausted, interrupt fired, or caller-cancelled.
  • used_artifacts is the exact list of artifacts the Composer read from the skill package when producing the prompt. The main graph records this on the invocation record so replay is deterministic when the skill has not changed.

The Composer runs a small, deterministic algorithm. It is not a long reasoning loop; it is a template-assembly step with a few decision points.

  1. Validate the package. Fail closed if the skill is missing required fields (name, framing), if the parameter schema is malformed, or if available_tools is empty when the skill requires tool use.
  2. Render the framing. Load the skill’s framing field. If the framing is a PromptTemplate artifact (authored as MiniJinja), render it against parameters. Otherwise treat it as literal prose.
  3. Select supporting artifacts. Walk the skill’s artifacts. Description artifacts whose include_when condition matches the invocation (scope, parameters, or unconditional) are added to the prompt body. Example artifacts are selected by relevance — an Example can declare tags; the Composer picks matching examples up to a budget (default: 3).
  4. Assemble the prompt. Concatenate: framing → selected descriptions → few-shot examples → current-invocation framing (which wraps user_request and parameters). Section headers and spacing follow a fixed template; the Composer does not reword the authored prose.
  5. Shape the subgraph. Decide tool availability (default: the full available_tools set; narrow if the skill’s Approach artifact declares a subset for this invocation’s parameters). Decide whether the subgraph is a single planner-executor loop or a multi-step pipeline (skills with a declared Approach artifact that describes ordered stages get a staged subgraph).
  6. Declare interrupts. Translate the skill contract’s named interrupt points into concrete graph-node references. A skill that declares an on_ambiguous_input interrupt produces a subgraph whose input-validation node fires that interrupt when its condition holds.
  7. Set exit condition. From the skill contract: either an output_schema match, a bounded tool-call count, or caller-supplied cancellation.
  8. Record used artifacts. Emit used_artifacts with the exact artifact ids and versions consulted.
  9. Return the composition.

The algorithm does not consult a model for steps 1–8 when the skill’s framing and Approach are authored prose. When the skill’s framing is a PromptTemplate that requires rendering against structured parameters, step 2 uses the sidecar’s MiniJinja implementation; that is a deterministic render, not a model call. The Composer does not call a model to “rewrite” authored prose.

The one path in which the Composer calls a model is step 5 when the skill explicitly declares planner_delegates_shape = true — a skill can opt into asking a model to decide subgraph shape from its Approach narrative. That is a skill-authored choice, not the default.

The composed subgraph runs on the same thread_id as the parent turn. This is intentional:

  • The checkpointer records the parent turn and the subgraph as one thread, making replay and time-travel meaningful.
  • Memory reads and writes during the subgraph are scoped the same way they would be for any other subagent (see Agent Memory System).
  • Interrupts fired inside the subgraph surface to the author with the parent turn’s context, not as a standalone “a skill interrupted” surface.

A sub-skill dispatched from inside a skill’s subgraph is also on the same thread_id. The entire tree of skill invocations for one user turn lives in one thread.

The Composer never widens capabilities. Concretely:

  • available_tools is the hard ceiling. The composition’s subgraph.tool_availability is a subset.
  • The composed prompt does not grant the model authority the caller lacks. A skill that says “use any tool you need” in its framing still runs inside the caller’s available_tools.
  • A nested sub-skill invocation’s available_tools is the intersection of the sub-skill’s tool_surface and the top-level caller’s caller_capabilities — not the parent skill’s declared surface. The surface declares intent; the capability check is always against the caller.

This bounding is enforced by the main graph when it calls the Composer (input construction) and again when it dispatches the composed subgraph (tool-availability check at tool-call time). The Composer is never the sole enforcer.

The Composer’s own tool surface (via MCP)

Section titled “The Composer’s own tool surface (via MCP)”

When the Composer is dispatched from a conversation whose purpose is creating or editing a skill (an authoring conversation, not an application conversation), it needs to call back into the Rust domain to write the skill. Those writes are exposed over MCP (see MCP System):

  • create_skill — create a new skill package with metadata.
  • update_skill — update an existing skill’s metadata or framing.
  • list_skills — list workspace skills with filters (source, tags, enabled).
  • add_artifact — add a typed artifact to a skill package.

These tools are exposed from the Rust side over MCP. The sidecar’s Composer calls them like any other MCP tool. They are not available to application-time Composer invocations — a skill running to produce content cannot edit its own definition. The planner only wires these tools into the Composer’s available_tools when the invocation source is an authoring conversation.

The underlying Rust implementation delegates to the application layer’s CRUD use cases against the SkillPackageRepository. The transport (MCP) and the domain layer are cleanly separated.

The Composer has a small set of failure modes, all of which return a structured error instead of a composition:

  • MissingRequiredField — the skill package is missing name, framing, or parameter schema.
  • MalformedTemplate — a MiniJinja render failed.
  • UnknownTool — the skill references a tool by name that is not in available_tools. The Composer does not silently drop the reference; it fails the composition so the author can correct either the skill or the caller’s capability set.
  • ParameterMismatch — reserved; parameter validation happens before the Composer runs, but the Composer repeats a lightweight check as a defensive measure.
  • ArtifactBudgetExceeded — the selected artifacts exceed a configured context-budget ceiling. The Composer returns the error with a suggested trimming, rather than arbitrarily dropping content.
  • CapabilityNarrowing — the intersection of skill.tool_surface and caller capabilities is empty for a skill that requires tool use. The planner should usually catch this before dispatch; if the Composer sees it, it fails closed.

Errors surface to the main graph’s planner, which decides how to proceed — typically by surfacing a message to the user and not attempting the skill.

Each Composer call produces an invocation record stored alongside the conversation’s turn record (not as a separate “execution trace” table — those were retired with the DSPy skill-compilation pipeline). The record contains:

  • Skill id and version.
  • Invocation source (explicit, agent-selected, scheduled).
  • used_artifacts list (ids + versions).
  • expected_tool_calls sequence hint.
  • Whether the composition succeeded; error variant if not.

The record is enough to answer “what did the Composer do?” without a separate trace system. Detailed per-artifact observability is a future expansion, not part of this landing.

  • Skill System — the surrounding framing; why skills are not world content.
  • Skill Storage — the entity shape the Composer reads.
  • Agent Core System — subagent dispatch, checkpointer, interrupts.
  • Agent Memory System — the four tiers whose items are pre-read into memory_context.
  • MCP System — the transport for the Composer’s skill-authoring tools.
  • LLM System — the Composer’s rare model calls (only when the skill explicitly opts in) go through this system.
  • Permission System — capability resolution that produces available_tools and caller_capabilities.
  • Submit Boundary — writes made by the composed subgraph cross the boundary; the composition itself does not.
  • It does not describe the data model of SkillPackage, SkillArtifact, or skill_permissions. See Skill Storage.
  • It does not describe the main graph’s planner logic that decides which skill to apply. See Agent Core System.
  • It does not specify a concrete prompt template. The assembly order is fixed; the text within each section is authored, per-skill.
  • It does not describe how the Composer is unit-tested. Testability follows ordinary sidecar subagent test patterns.

Was this page helpful?