M1.7
Build the spine
3,775 words · about 24 min
You now build the artefact this entire course refactors: a single-file harness with a loop, three tools, real tool schemas, and a dispatch switch. It is deliberately crude. It has no permission model, no cancellation, no compaction, no log, no streaming, and no tests — Modules 2 through 8 add each of those, and you will understand why each exists because you will first have felt its absence.
Concept
The minimum viable harness is: build a request, send it, look at the stop reason, run any tools, append the results, repeat.
The code
Save as spine.ts. It targets an OpenAI-compatible chat-completions endpoint
(DeepSeek's API is one; so are many others). Run with
npx tsx spine.ts "your task". It uses only fetch and Node built-ins.
#!/usr/bin/env node
// spine.ts — the Module 1 spine: a minimal agent harness in one file.
// Run: npx tsx spine.ts "count the lines in package.json"
import { readFile, writeFile } from 'node:fs/promises'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { resolve, relative, isAbsolute } from 'node:path'
const run = promisify(execFile)
const BASE_URL = process.env.DEEPSEEK_BASE_URL ?? 'https://api.deepseek.com'
const API_KEY = process.env.DEEPSEEK_API_KEY
const MODEL = process.env.DSH_MODEL ?? 'deepseek-chat'
const ROOT = process.cwd()
const MAX_STEPS = 20
const MAX_OBSERVATION_CHARS = 8_000
if (!API_KEY) { console.error('set DEEPSEEK_API_KEY'); process.exit(1) }
// ---------------------------------------------------------------- tool schemas
// This array IS the model's knowledge of what it can do. Descriptions are
// functional documentation: vague ones produce wrong calls.
const TOOLS = [
{
type: 'function',
function: {
name: 'read_file',
description: 'Read a UTF-8 text file, relative to the working directory.',
parameters: {
type: 'object',
properties: { path: { type: 'string', description: 'Path relative to the working directory.' } },
required: ['path'],
additionalProperties: false,
},
},
},
{
type: 'function',
function: {
name: 'write_file',
description: 'Write a UTF-8 text file, creating or overwriting it. Read before overwriting.',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: 'Path relative to the working directory.' },
content: { type: 'string', description: 'Full file content to write.' },
},
required: ['path', 'content'],
additionalProperties: false,
},
},
},
{
type: 'function',
function: {
name: 'run_shell',
description: 'Run a shell command in the working directory and return stdout and stderr.',
parameters: {
type: 'object',
properties: { command: { type: 'string', description: 'The command line to execute.' } },
required: ['command'],
additionalProperties: false,
},
},
},
] as const
// -------------------------------------------------------------------- effects
// Confine paths to ROOT. This is the only policy the spine has, and Module 5
// will show you why one containment check at one choke point is not enough.
function safePath(p: string): string {
const abs = resolve(ROOT, p)
const rel = relative(ROOT, abs)
if (rel.startsWith('..') || isAbsolute(rel)) throw new Error(`path escapes working directory: ${p}`)
return abs
}
// Every branch returns a string: the loop appends observations, never objects.
async function dispatch(name: string, args: Record<string, unknown>): Promise<string> {
switch (name) {
case 'read_file':
return await readFile(safePath(String(args.path)), 'utf8')
case 'write_file':
await writeFile(safePath(String(args.path)), String(args.content), 'utf8')
return `wrote ${String(args.path)}`
case 'run_shell': {
// No timeout, no sandbox, no approval. Module 5 fixes all three.
const { stdout, stderr } = await run(String(args.command), { shell: true, cwd: ROOT, maxBuffer: 10 << 20 })
return `stdout:\n${stdout}\nstderr:\n${stderr}`
}
default:
// An unknown tool is the model's error to recover from, not a crash.
throw new Error(`unknown tool: ${name}`)
}
}
// Unbounded tool output evicts the conversation. Truncate at the seam.
function observe(text: string): string {
return text.length <= MAX_OBSERVATION_CHARS
? text
: `${text.slice(0, MAX_OBSERVATION_CHARS)}\n…[truncated ${text.length - MAX_OBSERVATION_CHARS} chars]`
}
// ------------------------------------------------------------------- the call
type Msg = Record<string, unknown>
async function callModel(messages: Msg[]): Promise<any> {
const res = await fetch(`${BASE_URL}/chat/completions`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${API_KEY}` },
// The full conversation goes out every single time. This is M1.2, billed.
body: JSON.stringify({ model: MODEL, messages, tools: TOOLS, temperature: 0 }),
})
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`)
return await res.json()
}
// ------------------------------------------------------------------- the loop
async function main(): Promise<void> {
const task = process.argv.slice(2).join(' ')
if (!task) { console.error('usage: spine.ts "<task>"'); process.exit(1) }
const messages: Msg[] = [
{ role: 'system', content:
`You are a coding agent working in ${ROOT}. Use the provided tools to inspect and `
+ `modify files. Read a file before overwriting it. When the task is done, reply with a `
+ `short summary and no tool calls.` },
{ role: 'user', content: task },
]
for (let step = 1; step <= MAX_STEPS; step++) {
const reply = await callModel(messages)
const choice = reply.choices[0]
const msg = choice.message
messages.push(msg) // assistant turn joins the history
if (msg.content) console.log(`\n[assistant] ${msg.content}`)
// The stop reason is the loop's control signal — see M1.3.
switch (choice.finish_reason) {
case 'tool_calls':
break // fall through to execution below
case 'length':
console.error('\n[halt] reply truncated (max tokens) — output is incomplete')
return
case 'stop':
console.log(`\n[done] ${step} step(s)`)
return
default:
console.error(`\n[halt] unexpected finish_reason: ${choice.finish_reason}`)
return
}
for (const call of msg.tool_calls ?? []) {
let observation: string
try {
// Arguments arrive as a JSON *string* the model wrote; it may be invalid.
const args = JSON.parse(call.function.arguments || '{}')
console.log(`[tool] ${call.function.name} ${call.function.arguments}`)
observation = observe(await dispatch(call.function.name, args))
} catch (error) {
// A failed tool is an observation, not a crash: the model can correct it.
observation = `Error: ${error instanceof Error ? error.message : String(error)}`
console.log(`[tool] ${call.function.name} -> ${observation}`)
}
// Every result MUST cite its call id, or the transcript is malformed.
messages.push({ role: 'tool', tool_call_id: call.id, content: observation })
}
}
console.error(`\n[halt] step cap (${MAX_STEPS}) reached`)
}
main().catch((error: unknown) => { console.error(error); process.exit(1) })What to notice
Six design decisions in that file are the seeds of six later modules.
- The step cap.
MAX_STEPSis the crudest possible no-progress guard. Without it, a model that keeps callingread_fileon a missing path loops until your budget is gone. Module 2 replaces it with real loop hygiene. - The
finish_reasonswitch.lengthhalts rather than continuing, because continuing would append a truncated edit to a real file. - Errors as observations. A failed tool returns a string into the
conversation instead of throwing out of the loop. This is the single
highest-leverage line in the file: it is what lets the model recover from its
own mistakes, and it is exactly the instinct behind
parseArgumentsinpackages/core/agent-loop/src/tool-calls.ts. tool_call_idon every result. Correlation is structural. Drop it and the provider rejects the next request.- Truncation at the observation seam. One
run_shellon a verbose test suite would otherwise consume the context window in a single step. safePathis the entire permission model. It is a containment check with no approval flow, no allow-list, no sandbox, and no protection at all againstrun_shell— which cancd ..freely. That gap is not an oversight; notice how uncomfortable it is, and carry that discomfort into Module 5.
What is missing, by module
| Missing | Added in |
|---|---|
| Streaming, cancellation, retry, loop guards | M2 |
| Tool registry, argument validation, parallel execution, result shaping | M3 |
| Context budgeting, compaction, prompt caching | M4 |
| Approval flow, allow/deny policy, sandboxing, injection defence | M5 |
| Subagents and delegation | M6 |
| Plugins, hooks, extension points | M7 |
| Session log, replay, evals, telemetry | M8 |
Hands-on lab
Goal. Run the spine, then take it apart: move its three jobs — calling the
model, running tools, driving the loop — into three plugins on a bare Cordis
host, and prove the same task still works. Then swap your model plugin for
dsh's and see that nothing else notices.
Parts 1–4 use the monolith in labs/01-foundations/. Parts 5–8 use
labs/harness/, the project you keep for the rest of the course. Criteria
marked (manual) are yours to judge; write the answers in
labs/harness/notes/01.md. All the others are checked by
npm run check -- 01, which needs no API key.
Part 1 — Run it (30 min).
- Open
labs/01-foundations/and runnpm install. It holds the spine exactly as printed above, plus a small sample project inworkspace/for the agent to work on. - Export your API key (
DEEPSEEK_API_KEY) and, if needed,DEEPSEEK_BASE_URL. Do not commit either. - Run
npm run spine -- "summarise what this project does".
Success criteria: [L1.1] (manual) the agent calls read_file at least
once and finishes with finish_reason: stop, and you can explain each call in
the tool trace.
Part 2 — Make the cost visible (20 min).
Print reply.usage after every call and keep a running total of input
tokens. Run a task that takes at least four steps.
Success criteria: [L1.2] (manual) you can state the total input tokens billed, show that it exceeds the size of the final conversation, and explain the gap in one sentence using M1.2. If your provider reports cache-hit fields, note which portion was cached, and whether those fields overlap the prompt-token count or are disjoint from it.
Part 3 — Break the correlation (15 min).
Delete tool_call_id from the result message and rerun.
Success criteria: [L1.3] (manual) you can describe exactly what the provider does (usually a 4xx). Restore the line.
Part 4 — Add a fourth tool (30 min).
Add list_files (a directory listing that respects safePath). Give it a
vague description first ("lists things") and watch how the model uses it.
Then make the description precise and rerun the same task.
Success criteria: [L1.4] (manual) you can name one concrete behavioural difference caused by the description alone, and say why tool descriptions are part of the prompt.
Set up the harness (10 min).
cd labs/harness
npm ci
npm run lab -- start 01lab start 01 copies three stub plugins into plugins/ and your notes file
into notes/01.md, and prints the rows to add to cordis.yml. It never
overwrites a file you already have. Add the rows:
- id: llm-adapter
name: ./plugins/llm-openai/index.ts
config:
baseURL: !!js process.env.LLM_BASE_URL
apiKey: !!js process.env.LLM_API_KEY
- id: tools
name: ./plugins/tools-basic/index.ts
config:
root: !!js process.env.HARNESS_ROOT || process.cwd()
- id: loop
name: ./plugins/loop-basic/index.ts
config:
model: !!js process.env.LLM_MODEL
trace: !!js process.env.HARNESS_TRACE === '1'The host, host.ts, has nothing built in. ctx.llm exists only because the
first row mounts @deepseek-ai/dsh-llm, and everything else exists only
because you add it. Keys reach plugins only through !!js process.env.
Never type one into this file. Skip a row and the checks name it instead of
crashing bare: a fresh npm run check -- 01 fails with, for example, "no
tools service: add the tools row from checks/01-foundations/rows.yml"
until you add it.
Part 5 — The llm plugin (45 min).
In plugins/llm-openai/index.ts, register one adapter on ctx.llm that
speaks OpenAI-compatible chat completions with stream: true. Read the SSE
body (kit/sse.ts gives you readSseData) and translate it into dsh's
StreamChunks: block-start, deltas, block-end, then usage, then
finish. The checks mount dsh's stream invariant, so an out-of-order chunk
fails loudly, just as it would in dsh. Validate a supplied key with
dsh-llm's assertUsableApiKey(raw, pkg, ref), which trims it silently but
refuses anything that can't ride an HTTP header, without ever echoing it back.
Success criteria:
- [L1.5] a text reply streams through the invariant, with
usagebeforefinish: stop. - [L1.6] a streamed tool call becomes one
tool-callblock with the provider's id and the raw JSON arguments, and finish istool-calls. - [L1.7] each request carries the Bearer key, the model,
dsh's attribution headers, the system prompt and the tool schemas, and posts to<base>/chat/completionswhether or not the base URL ends in a slash. - [L1.8] a 401 is a failure — code
AUTHorINVALID_CREDENTIAL(the reference adapter usesAUTH, and the check accepts either) — never a success; a missing key fails before any request with a message that namesLLM_API_KEY; astopfinish with no content and no tool calls fails withEMPTY_RESPONSE_CODE, not a silent empty success;finish_reason: lengthbecomesmax-tokens.
Part 6 — The tools plugin (30 min).
In plugins/tools-basic/index.ts, move the spine's three tools behind the
tools service: schemas() returns their schemas and execute() runs one.
The shape is dsh's ToolRuntime, cut down to those two methods.
Success criteria:
- [L1.9]
schemas()listsread_file,write_fileandrun_shell, and a write followed by a read round-trips under the root. - [L1.10] a path that escapes the root, an unknown tool and a missing file each come back as an error result, never a throw.
- [L1.11]
run_shellruns in the root and returns stdout; a long observation is truncated with a note of how much was cut.
Part 7 — The loop plugin (45 min).
In plugins/loop-basic/index.ts, rebuild the spine's loop on ctx.llm and
ctx.tools. Use dsh's BlockAssembler to turn a step's stream into
blocks. That is pitfall 1 below, solved once.
Success criteria:
- [L1.12] a tool call runs, its result goes back citing the same call id,
and the run ends
stopafter two steps; usage is summed over steps; every request resends the whole conversation. - [L1.13]
max-tokenshalts without running tools; the step cap stops the run with one request per step; invalid tool-call JSON becomes an error observation the model can see. - [L1.14] live, with your own key: asked to create
hello.txtcontainingharness, a real model does it through your tools.
Run the live check and try the harness yourself:
cp .env.example .env # then set LLM_PROVIDER and LLM_API_KEY in .env
npm run check:live -- 01
npm run live -- "summarise what this project does"Any OpenAI-compatible endpoint works. Set LLM_PROVIDER=deepseek or
LLM_PROVIDER=openrouter for the base URL and a default model, or set
LLM_BASE_URL and LLM_MODEL yourself for OpenAI, Ollama or LM Studio.
labs/README.md has the details.
Part 8 — Swap in dsh's adapter (20 min).
Run your checks with the llm-adapter row replaced by
@deepseek-ai/dsh-llm-deepseek:
npm run check -- 01 --patch swaps/llm-deepseek.patch.ymlSuccess criteria:
- [L1.15] the same task completes with
dsh's adapter mounted in your row, and yourtoolsandloopplugins do not change. - [L1.16] (manual) read
packages/llm/llm-deepseek/src/at the pinned commit and note three things its adapter does that yours does not, each with a file and line.
dsh-llm-deepseek@0.1.1-rc.2 matches the reference plugin's AUTH mapping
for HTTP 401 and 403, but it builds its request URL by plain concatenation,
${baseURL}/chat/completions, with no trailing-slash handling: a base URL
with a trailing slash yields a double slash. That is why the base-URL check
from Part 5 (L1.7) is reported as skipped under this swap — that is
expected, not a bug in your plugin, and good material for the L1.16
write-up.
Stretch — Find the seam (25 min).
Use the M1.6 procedure on your dsh checkout to answer: what would you
change to add a new model provider? Start from docs/capability-seams.md,
find the ctx.llm row, and open packages/llm/llm/src/index.ts.
Success criteria: [L1.17] (manual) your list contains new files and no
edits to packages/core/agent-loop — which is exactly what you did in
Part 5. If your list has such an edit, you have found either a leak or a
misreading. Say which.
Fell behind? Point any row at the course's version, for example
name: ./reference/loop-basic/index.ts, and carry on. Reading the reference
after writing your own is part of the lab.
Common pitfalls
1. Parsing tool arguments before the block is complete. Tool arguments stream
as string fragments (argumentsDelta in StreamChunk; a JSON-fragment delta or
tool_calls[].function.arguments on the wire). Calling JSON.parse on a
fragment throws on nearly every chunk. Fix: accumulate fragments keyed by block
index and parse once at the end of the block. dsh exports a BlockAssembler
(packages/llm/llm/src/assembler.ts) for exactly this.
2. Treating max-tokens as a normal stop. A truncated reply looks like a
complete one if you only check for the presence of tool calls. Fix: switch on
the stop reason explicitly and halt, or continue deliberately with a
continuation strategy. Never write a truncated edit to disk.
3. Letting a tool failure throw out of the loop. One malformed argument or
one missing file takes down the whole turn and loses the work. Fix: catch at
the dispatch boundary and return the error as an observation so the model can
correct itself. parseArguments in
packages/core/agent-loop/src/tool-calls.ts preserves invalid JSON as text
rather than throwing, for precisely this reason.
4. Emitting a tool call with no matching result. This happens on
cancellation, on crash, and on any early return between call and result. The
resulting transcript is often unresumable and the provider may reject it
outright. Fix: guarantee a result for every call, synthesising an error result
when the call was skipped — the appendSkippedToolCall pattern.
5. Appending unbounded tool output. A single npm test can emit more tokens
than your context window holds, evicting the whole conversation in one step.
Fix: truncate or summarise at the observation boundary, keep head and tail, and
tell the model that truncation occurred.
6. Destabilising the front of the prompt. A timestamp, a randomly-ordered
tool list, or a per-call temperature tweak invalidates the provider's prefix
cache and multiplies your bill with no visible symptom. Fix: treat everything
before the volatile tail as immutable; keep sampling config as compared, logged
state, as callConfigEquals does in packages/llm/llm/src/call-config.ts.
7. Reviewing only one role of a seam. Approving a new adapter without reading
the definition and the consumers is how vendor concepts leak into core code.
Fix: review all three roles together — definition, providers, consumers — per
the AGENTS.md rule that a seam "is complete, never one role."
8. Freezing the abort signal. Deep-freezing a request object is good hygiene
until it freezes the AbortSignal and silently breaks cancellation. Fix:
exempt it explicitly, as deepFreeze does in
packages/llm/llm/src/call-config.ts.
9. Double-counting cached tokens. Providers disagree about whether the
prompt-token count includes cache hits. Summing fields from two providers with
different conventions produces a metric that is wrong in opposite directions.
Fix: pick one convention — dsh picks disjoint counts — and make each adapter
normalise into it, as mapUsage does in
packages/llm/llm-deepseek/src/translate.ts.
Key terms
- Agent harness — the program that turns a stateless model into a durable, tool-using, interruptible process acting on real systems.
- Turn — one drain of admitted input in a session; contains zero or more steps.
- Step — one model request plus the tool executions caused by its response.
- Round — an outer policy iteration containing a turn.
- Context window — the maximum token span a model can attend to in one request; the hard budget every composition decision spends against.
- Tool — a named, schema-described capability the model can request; the harness executes it.
- Tool schema —
{ name, description, parameters }whereparametersis JSON Schema; the model's only knowledge of a tool. - Tool call / tool result — the model's structured request and the harness's observation answering it; correlated by call id.
- Observation — tool output as appended to the conversation, after shaping and truncation.
- Stop reason (finish reason) — why generation ended:
stop,tool-calls,max-tokens,aborted,error. - Streaming delta — an incremental fragment of a response block.
- Provider adapter — the component translating provider wire format into the
harness's neutral vocabulary; in
dsh, anLlmAdapter. - Capability seam — a boundary comprising Service Definition, Service Provider(s), and Consumer(s); complete only as all three.
- Service Definition — the runtime object owning a
ctx.<key>and its vocabulary types. - Prompt caching — provider-side reuse of a stable prompt prefix at reduced cost; defeated by any mutation of that prefix.
- Compaction — replacing a prefix of history with a summary to reclaim context budget.
- Session log — the append-only record of typed events from which model
history is derived (
Model-visible ⟺ logged). - Branded id — a nominal string type (e.g.
CallId,SessionId) preventing accidental substitution across boundaries. - Waterfall — a dispatch mode where each listener may call
next()to delegate, enabling interception without editing the interceptee.
Summary
Four things should have changed in your mental model.
The model is the smallest part. You began, most likely, thinking of an agent as a clever model with some plumbing. It is more accurate to think of it as a control program that occasionally consults an oracle. The loop, the context budget, the permission boundary, and the failure semantics are the product; the model is a component with a documented interface.
Statelessness is the source of the hard problems. Cost curves, latency curves, caching discipline, compaction, and half of the context-engineering literature all fall out of the single fact that every request resends everything.
The stop reason is control flow. stop, tool-calls, max-tokens,
aborted, error are five different obligations. Collapsing them is how
harnesses corrupt files and spin forever.
Seams are read in threes. Definition, providers, consumers. You now have a
repeatable procedure for opening any harness — dsh, Claude Code, Codex, or a
codebase your team wrote last year — finding the model boundary, and predicting
what a provider swap would cost.
And you have the spine: roughly 150 lines that genuinely work and are genuinely inadequate. Every module from here refactors it, and each refactor is motivated by a failure you can now name.
Further reading
In the dsh repository
AGENTS.md— repository conventions; the source of the seam, effect, and logging rules quoted throughout.docs/architecture.md— core package map, the turn-flow listing, and the "where new behavior goes" table.docs/capability-seams.md— the generated seam census; start here for any "who owns this?" question.docs/glossary.md— canonical definitions of turn, step, round, scope, and seam.docs/subsystems/core.md— theAgentsurface, session event variants, and theagent/*event catalog.packages/llm/llm/src/types.ts—GenerateOptions,StreamChunk,FinishReasonMap,ToolSchema,TokenUsage.packages/llm/llm/src/index.ts—LlmRuntimeandLlmAdapter: the model seam in one file.packages/llm/llm/src/message.ts—Message,MessageSourceMap, and the tool-result construction helpers.packages/llm/llm/src/call-config.ts— request-header state,callConfigEquals,deepFreeze.packages/llm/llm-deepseek/src/translate.ts— a real wire-to-neutral translation, including finish-reason and usage normalisation.packages/core/agent-loop/src/tool-calls.ts— scheduling, abort semantics, and synthetic results.
Outside
- Your provider's streaming and tool-use API reference — read the actual wire format for at least two providers; the differences are the reason adapters exist.
- Provider prompt-caching documentation — the rules about what invalidates a prefix are the constraint behind most context-assembly design.
- The Model Context Protocol and Agent Client Protocol specifications — the current attempts at standardising the tool seam and the client seam respectively. Both are young; read them as proposals, not as settled ground.
- Published postmortems and security write-ups on agent tool use — the failure modes in Module 5 are not hypothetical.
No drill for this section
This section is assessed by the work you produce, not by questions.