L5 — Assemble context deliberately
Goal. By the end of this lesson you control what the model knows at each step: you inject context from a plugin, you ship a skill the agent discovers without being told, and you add a /command that runs entirely in code.
Why here. L4 was about constraining the model. This lesson is about informing it — the context-engineering half of the harness, and the prerequisite for the durable state you add in L6.
Concepts taught
| Concept | What you learn |
|---|---|
agent/pre-step | The waterfall that runs before a request is admitted |
agent/request | The waterfall around the outgoing model request |
agent.inject() | Append durable context the next admitted request sees |
| "Not a wake-up" | Injected context does not wake an idle agent |
| Instruction loading | How AGENTS.md/CLAUDE.md reach context, and why placement matters |
| Skills | A SKILL.md catalog discovered from scanned roots, body loaded on demand |
| Human commands | ctx.commands.register() — dispatches without a model turn |
Reference: workspace instruction loading, the repository's skill filesystem README and commands README.
Prerequisites
L1–L4 complete. You need the diagnose instrument, the reload loop, and the habit of separating what a boot proves from what a session proves.
Step 1 — Watch the turn flow before touching it
Open <kit>/kit-plugins/l5/turn-observer.js, wired into the bundle:
export const name = 'l5-turn-observer'
export function apply(ctx) {
ctx.on('agent/pre-step', async (payload, next) => {
console.log(`[l5-observer] pre-step turn=${payload.turn} step=${payload.step} messages=${payload.messages?.length ?? '?'}`)
const decision = await next()
const sources = (decision?.messages ?? []).map(message => message.source?.kind ?? '?')
console.log(`[l5-observer] pre-step decision kind=${decision?.kind ?? '?'} message sources: ${sources.join(',') || '(none)'}`)
return decision
})
ctx.on('agent/created', ({ agent }) => {
console.log(`[l5-observer] agent created: ${agent.session.id ?? '(session)'}`)
})
console.log('[l5-observer] ACTIVE — watching agent/pre-step')
}Never JSON.stringify the payload. payload.agent is reached through a Cordis context proxy, and JSON.stringify probes a value for a toJSON method before serializing — a property read that lands on the proxy, where Cordis answers cannot get property "toJSON" without inject. The error is thrown from inside your listener, which aborts the whole turn: this one line killed every turn in this kit's composition until it was found (ADR-0028). Log the fields you need by name.
Logging the decision is the other half of the step: next() runs the remaining agent/pre-step listeners, so the message sources it returns are what the request will actually carry. On a real turn you will see the other contributed layers arrive this way — including skill-catalog, which Step 3 builds.
The declared payload is:
'agent/pre-step'(payload: {
agent: Agent
messages: UserMessage[]
turn: number
step: number
signal: AbortSignal
}, next: () => Promise<PreStepDecision>): Promise<PreStepDecision>Take the shape from the declaration, not from the event name. The payload is an object, and the subject is payload.agent — the same trap that bit this kit's own agent/created listeners, where reading payload.session instead of payload.agent.session produced undefined and, behind a try/catch, a plugin that silently did nothing.
Boot the profile and start a conversation, then read what actually arrives there. The declaration says which fields exist; only a real turn shows you what is in them, and guessing either is the usual source of broken context plugins.
agent/pre-step is a waterfall: return next() to delegate. Several first-party plugins already listen here, including agent-instructions, plan-mode, tool-skill, time-context, and session-checkpoint-policy. You are joining an existing layer, not replacing it.
No API key? The registration is observable at boot — the plugin prints that it is watching. The payload's shape is observed too, from a real turn against the mock provider: the observer logs pre-step turn=1 step=1 messages=2, and solutions/verify-l5.sh runs it.
Step 2 — Inject context deliberately
<kit>/kit-plugins/l5/inject.js appends context:
import { createUserMessage } from '@deepseek-ai/dsh-llm'
export const name = 'l5-inject'
export const inject = ['agents']
export function apply(ctx) {
// The payload is `{ agent, source, signal }`, not the agent: destructure it.
ctx.on('agent/created', ({ agent }) => {
try {
agent.inject(createUserMessage({
content: [
{
type: 'text',
text: 'Exploration mode: when you explain a change, name the file it lands in.',
},
],
source: { kind: 'l5-inject' },
}))
console.log('[l5-inject] context appended to the next admitted request')
} catch (error) {
// The agent may already be disposed; never let a notification kill a plugin.
// Keep it LOUD — a silent catch hides a wrong payload shape, which is how this
// plugin previously did nothing at all without anyone noticing.
console.log(`[l5-inject] FAILED to inject: ${error.message}`)
}
})
console.log('[l5-inject] ACTIVE — appends durable context on agent/created')
}Three contract points, each verified against the runtime's own types:
agent.inject()takes a completeUserMessage, not a loose object. Build it withcreateUserMessage, which fills in the role and a stable identity.- The source kind is producer-owned. Each producer declares its own kind; there is deliberately no catch-all
pluginkind to reuse. The upstream cookbook showssource: { kind: 'plugin', plugin: '…' }, which is stale — that shape is not aUserMessagesource and will not typecheck. - It is not a wake-up. Injected context lands in the next admitted request; an idle agent stays idle. Guard against a disposed agent, as above.
agent/createdhands you a payload, not an agent. The listener receives{ agent, source, signal }, so it is({ agent }) => …— not(agent) => …. Getting this wrong is quiet:agent.sessionisundefined, and atry/catcharound it turns the whole plugin into a no-op that logs a cheerful success message. That is why the catch here saysFAILEDrather thanskipped, and why this lesson's probe creates a session — an agent-created listener that never fires is invisible until one exists.
Confirming the durability claim — in two processes, with no model
Durability cannot be checked in one process. agent/created fires while the session is being built, so an in-process assertion measures the queue, not the log. The kit ships a probe that does it properly:
export L5_SESSION_ID="session-l5-check-$RANDOM"
dsh --profile kitdemo --patch <kit>/solutions/l5.probe.patch.yml --port 0 --no-open # creates a session
dsh --profile kitdemo --patch <kit>/solutions/l5.read.patch.yml --port 0 --no-open # a FRESH process[l5-probe] re-read 5 event(s): permission/preset, sandbox/mode, approval/policy, agent/inbox/spliced, agent/inbox/spliced
[l5-probe] injected text present after restart: true
[l5-probe] carried by: agent/inbox/splicedThe carrier is worth noticing: an inbox event, not a user/message. agent.inject() queues into the agent's inbox, and that is the durable record. It is also a first-party event type, which is why the log stays readable — the constraint ADR-0024 records, and the reason L6's original approach had to be rebuilt.
bash <kit>/solutions/verify-l5.sh runs both phases and asserts the text is still there.
That is the mechanism L6 builds on: the log is the source of truth, and anything durable about a session is in it.
Step 3 — A skill the agent discovers on its own
Skills are the right delivery mechanism for knowledge too large to inject on every turn and too specific to put in AGENTS.md. The kit ships one at <kit>/kit-plugins/l5/skills/repo-onboarding/SKILL.md:
---
name: repo-onboarding
description: Use when asked to explain how the DSH exploration kit is organized, where its knowledge lives, or how to get started reading it.
---
# Kit onboarding
The DSH Exploration Kit is a nine-lesson curriculum for learning DeepSeek Harness by
building real plugins. Its layout:
...Frontmatter rules that bite if ignored: name must be kebab-case, description is required, and discovery is exactly one level deep — <root>/<name>/SKILL.md or <root>/<name>.md. A nested **/SKILL.md is deliberately not discovered. An invalid skill is skipped with a warning, so from the model's point of view a broken skill looks identical to an absent one — a genuinely nasty failure mode, and the reason the description deserves care.
Point a scanned root at the kit's skills with the shipped overlay, <kit>/solutions/l5.skills.patch.yml:
- id: skill-filesystem
config:
customSkillDirs: [!!js "process.env.KIT_ROOT ? process.env.KIT_ROOT + '/kit-plugins/l5/skills' : undefined"]
includeDefaultRoots: falseTwo things about that shape:
- It is an override, not an insert.
customSkillDirsis a config field on an existing base-bundle row, so the entry has noinsertand noname. This is the same in-place override you used in L2. includeDefaultRoots: falsemakes the experiment unambiguous. Root resolution order is project roots, thencustomSkillDirs, then user roots; disabling the default roots means only the kit's skill can appear.
Boot with it:
KIT_ROOT=<kit> dsh --profile kitdemo --patch <kit>/solutions/l5.skills.patch.yml --port 0 --no-openThe directory need not exist yet — a missing root is probed until it appears, and existing roots are watched, so adding or renaming a skill reaches the next catalog without a restart.
The catalog/body split is the design worth noticing: a model-invocable skill gets a durable catalog of names and capped descriptions before the first request, and the body loads only when the agent calls the skill tool. That is why description quality decides whether a skill is ever used.
No API key? The overlay composes (visible in --dump-config), the profile boots with it, and what the model sees is asserted against the mock provider: the session log carries the skill's name while the body stays out of it. That pair — announced, not shipped — is the whole design described here.
Step 4 — A command that needs no model turn
ctx.commands.register() gives a human a deterministic entry point. <kit>/kit-plugins/l5/commands.js registers one:
export const name = 'l5-commands'
export const inject = ['commands']
export function apply(ctx) {
ctx.commands.register({
name: 'l5-facts',
description: 'Print how the DSH exploration kit is laid out',
handler: () => ({
kind: 'success',
text: [
'content/ the curriculum (OKF bundle) — start at content/index.md',
...
].join('\n'),
}),
})
console.log('[l5-commands] ACTIVE — /l5-facts registered')
}A command line starts with /, a lowercase name, then either end-of-input or whitespace; everything after the name is rawInput and the command owns its grammar. Registering the same name twice in one scope throws. The handler returns success or error plus optional UI text.
Type /l5-facts in the composer and confirm the reply is immediate. That the handler runs from plain code is testable rather than merely asserted — the kit's probe dispatches the command through ctx.commands.execute, the same path the composer uses:
dsh --profile kitdemo --patch <kit>/solutions/l7.patch.yml \
--patch <kit>/solutions/l5.cmd.patch.yml --port 0 --no-open[l5-cmd] resolved: true
[l5-cmd] outcome: {"kind":"success","text":"content/ the curriculum (OKF bundle) — …"}
[l5-cmd] command events in the log: command/run, command/done
[l5-cmd] model-request events in the log: 0The last line is the claim. The session records the command's lifecycle events and zero model-request events — which is what "needs no model turn" means in the log, rather than a claim about responsiveness. (ctx.commands.execute takes an Agent, and ctx.agents.create makes one without running a turn, which is why this is reachable offline.)
Step 5 — Decide where knowledge belongs
You now have four delivery mechanisms, and choosing correctly is most of the skill:
| Mechanism | Use it for | Cost model |
|---|---|---|
AGENTS.md / CLAUDE.md | Workspace-wide rules that always apply | Loaded into every session |
agent.inject() | Facts discovered at runtime for the next request | Per-injection, durable in the log |
| Skills | Procedures and reference too large to inject | Catalog always, body on demand |
| Commands | Deterministic actions with no reasoning needed | No model turn at all |
Placement has a correctness dimension, not just a cost one. The agent-instructions concept records the rule and a real mistake from this very bundle: ~/.dsh/AGENTS.md loads in every dsh session on the machine, so anything workspace-specific placed there bleeds into unrelated projects. Workspace-root AGENTS.md is correct for workspace-specific knowledge even though it is less guaranteed to fire. Correctness of scope beats the convenience of "always loads".
Verification
Observable without a model:
- The boot prints all three activation lines:
[l5-observer] ACTIVE,[l5-inject] ACTIVE,[l5-commands] ACTIVE. --dump-configwith the skills overlay shows the composedskill-filesystemrow carrying yourcustomSkillDirsandincludeDefaultRoots: false.- Removing
inject = ['agents']or['commands']makes the load fail loudly, as L4 showed — proof neither service is ambient.
Requires a session, and therefore a provider:
- Your observer prints the
pre-steppayload shape for a real turn. - Injected text is present in the replayed session after a restart.
- Your skill appears in the catalog, and
name/descriptionare exactly right. - Adding a skill to a watched root changes the catalog without a restart.
/l5-factsresponds with no model turn and nopre-steplog line.
All eight are executed and recorded in VERIFIED.md. Item 4's payload shape is observed from a real turn; item 6 is the pair described above — the catalog arrives without the body, and the body appears when the model calls the skill tool; and item 7 is verified by writing a new skill into a watched root and running a second session in the same process, with no restart (a second session rather than a second turn is deliberate: the catalog arrives as durable context).
Exit check — you should now be able to explain
- Why
next()in a waterfall is mandatory for an observing listener. - Why injected context is not a mechanism for waking a background agent.
- What the catalog/body split buys, and what a missing
descriptioncosts you. - Which of the four mechanisms you would use for: a coding standard, a discovered API fact, a 40-step release procedure, and a cache-clearing action.