Certified Agent Harness Engineer
Course sections

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_STEPS is the crudest possible no-progress guard. Without it, a model that keeps calling read_file on a missing path loops until your budget is gone. Module 2 replaces it with real loop hygiene.
  • The finish_reason switch. length halts 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 parseArguments in packages/core/agent-loop/src/tool-calls.ts.
  • tool_call_id on every result. Correlation is structural. Drop it and the provider rejects the next request.
  • Truncation at the observation seam. One run_shell on a verbose test suite would otherwise consume the context window in a single step.
  • safePath is the entire permission model. It is a containment check with no approval flow, no allow-list, no sandbox, and no protection at all against run_shell — which can cd .. freely. That gap is not an oversight; notice how uncomfortable it is, and carry that discomfort into Module 5.

What is missing, by module

MissingAdded in
Streaming, cancellation, retry, loop guardsM2
Tool registry, argument validation, parallel execution, result shapingM3
Context budgeting, compaction, prompt cachingM4
Approval flow, allow/deny policy, sandboxing, injection defenceM5
Subagents and delegationM6
Plugins, hooks, extension pointsM7
Session log, replay, evals, telemetryM8

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).

  1. Open labs/01-foundations/ and run npm install. It holds the spine exactly as printed above, plus a small sample project in workspace/ for the agent to work on.
  2. Export your API key (DEEPSEEK_API_KEY) and, if needed, DEEPSEEK_BASE_URL. Do not commit either.
  3. 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 01

lab 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 usage before finish: stop.
  • [L1.6] a streamed tool call becomes one tool-call block with the provider's id and the raw JSON arguments, and finish is tool-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/completions whether or not the base URL ends in a slash.
  • [L1.8] a 401 is a failure — code AUTH or INVALID_CREDENTIAL (the reference adapter uses AUTH, and the check accepts either) — never a success; a missing key fails before any request with a message that names LLM_API_KEY; a stop finish with no content and no tool calls fails with EMPTY_RESPONSE_CODE, not a silent empty success; finish_reason: length becomes max-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() lists read_file, write_file and run_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_shell runs 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 stop after two steps; usage is summed over steps; every request resends the whole conversation.
  • [L1.13] max-tokens halts 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.txt containing harness, 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.yml

Success criteria:

  • [L1.15] the same task completes with dsh's adapter mounted in your row, and your tools and loop plugins 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 } where parameters is 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, an LlmAdapter.
  • 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 — the Agent surface, session event variants, and the agent/* event catalog.
  • packages/llm/llm/src/types.ts — GenerateOptions, StreamChunk, FinishReasonMap, ToolSchema, TokenUsage.
  • packages/llm/llm/src/index.ts — LlmRuntime and LlmAdapter: 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.