agents-handoff Cross-harness capture and verified continuation for AI working sessions.

Integrating agents-handoff

The engine is a command-line program with a file contract. There is no daemon, no network call and no database. Integration means three things: getting a transcript into the canonical input shape, running the engine, and reading the artifacts it writes.

Input contract

Every input line is one JSON object (JSONL). Any program that can write JSONL can feed the engine; no client is required.

{"seq":0,"ts":"1787669764492","harness":"dsh","source":"<origin path>",
 "session":"<id>","thread":"<project/thread>","role":"user|assistant|system|tool",
 "kind":"<free-form: reasoning|tool_use|text>","text":"<message body>"}
Field Used for Notes
seq ordering, watermark Falls back to the line index when absent or non-numeric.
ts timeline ordering timestamp is accepted as an alias.
role turn class user, assistant, system, tool.
kind turn class refinement tool_use → TOOL, reasoning/thinking → THOUGHT.
text the turn body content and parts[].text are accepted as aliases.
session handoff id Used when --session is not passed.
thread project inference A --tag-- inside the thread name is read as the project.
harness provenance Not read from the line. Set it with --harness; otherwise it is unknown.
source provenance Not read from the line. source_paths records the file given to --source.

harness and source are carried for whatever reads the file later. The engine itself reads seq, ts, role, kind and text, plus session and thread from the first line.

Turn classification order (classify() in handoff.mjs):

  1. kind contains tool, or role is tool → TOOL
  2. kind contains reason or think → THOUGHT
  3. role is user, or kind is human → USER
  4. role is assistant, or kind is ai → AGENT
  5. anything else → OTHER

A line whose body is empty after that mapping is skipped. A file whose name ends in .jsonl is parsed as JSONL; every other file is parsed as text, using role markers:

user: what needs to happen next
assistant: the plan is in refs/plan.md

The marker is user|human|assistant|ai|system|tool, optionally prefixed by # and followed by : or >. Lines after a marker are appended to that turn until the next marker. A system: line becomes OTHER, so it stays in the record without appearing as dialogue.

If no usable turn is parsed, the build fails with exit 4 rather than writing an empty handoff. A JSONL line that does not parse fails the build with exit 5 instead of being skipped silently; --allow-bad-lines accepts a damaged source on purpose and warns about how many lines it skipped.

Build a handoff

node tools/handoff.mjs build --source transcript.jsonl \
  --session my-session --harness claude-code --model <name> \
  --project my-project --objective "what this session was for"
Flag Effect
--source <file> The transcript to ingest. Required.
--session <id> Handoff id. Default: the session field, else the file name.
--harness <name> Provenance label. Default unknown; the engine reads no harness field.
--model <name> Provenance label. Stored, never invented.
--project <name> Project group. Default: config, then the --tag-- in the thread, then the harness.
--objective <text> Overrides the objective taken from the transcript.
--force-harness Rewrite an existing harness value. Without it, a set value is kept.
--force-model Rewrite an existing model value. Without it, a set value is kept.

node tools/handoff.mjs --handoff --source transcript.jsonl is an alias of build.

A rebuild of the same session is incremental: only turns above the manifest watermark are appended, and a source whose bytes and watermark are unchanged prints up-to-date and writes nothing.

Where handoffs are stored

The root is resolved by one module, tools/lib/handoff-root.mjs, in this order:

# Source Detail
1 HANDOFFS_ROOT environment variable Always wins. This is what makes a CI run hermetic.
2 handoff.config.json Found by walking up from the current directory.
3 a handoffs/ directory Found by walking up from the current directory.
4 the skill directory Final fallback when nothing else exists.

Inside a config, storage.path beats handoff_dir, and a relative handoff_dir resolves against the config’s own directory. The walk is bounded to ten levels. A config file that is present but invalid fails the run with exit 2 — it is never ignored, because falling back would write to a different store than the one configured.

node tools/handoff.mjs config prints the resolved root, the source that decided it, the config path, the schema path, the project name and whether cross-linking is on.

Output artifacts

<root>/
├── INDEX.json                        # rebuilt from every manifest
├── projects/<project>/
│   ├── PROJECT.md                    # project name, session list
│   └── <session>/
│       ├── HANDOFF.md                # the brief a human or agent reads
│       ├── HANDOFF.summary.json      # compact payload (schema 2.0.0-summary)
│       ├── HANDOFF.llm.json          # full payload (schema 2.0.0)
│       ├── timeline.jsonl            # append-only, one turn per line
│       ├── TOOLS.md                  # every tool call, verbatim
│       └── manifest.json             # provenance and counters
└── links/<other-project>.md          # cross-project relation notes

TOOLS.md is the fidelity tier: it carries each tool call in full. The table inside HANDOFF.md is a digest of the same turns.

Manifest fields

Field Meaning
session, project, harness, model Identity and provenance.
created_at, updated_at First write, last write.
source_paths Every source file ingested for this session.
watermark Highest ingested seq. Everything below it is already in the timeline.
raw_sha256 Hash of the source bytes at the last build.
revisions Build count for this session.
turn_count Lines in timeline.jsonl.
counts Turn count per class: USER, AGENT, THOUGHT, TOOL.
manifest_sha256 Hash of the manifest without this field.

Verify a handoff

node tools/handoff.mjs list                 # all sessions
node tools/handoff.mjs list <project>       # one project
node tools/handoff.mjs show <id-prefix>     # print the brief
node tools/handoff.mjs verify <id-prefix>   # manifest sha, turn count, payload parses

verify recomputes the manifest hash, compares the timeline line count against turn_count, and parses HANDOFF.llm.json. It prints PASS <id> [...] or fails with exit 1 and the first broken check. See PROVENANCE.md for what the hash chain does and does not detect.

Exit codes

Code Meaning
0 Success.
1 Integrity failure: tampered manifest, unparsable manifest, unexpected error.
2 Usage or configuration error: missing --source, missing argument, invalid config.
3 Ambiguous id prefix — more than one session matched.
4 No usable turns, no handoffs, or no session matching the prefix.
5 Unparseable JSONL line(s), unless --allow-bad-lines was passed.

CI recipe

- name: Build and verify the handoff
  env:
    HANDOFFS_ROOT: $/.handoffs
  run: |
    node skills/agents-handoff/tools/handoff.mjs build \
      --source exported-transcript.jsonl --project ci --harness generic
    node skills/agents-handoff/tools/handoff.mjs verify "$(ls .handoffs/projects/ci | head -1)"

HANDOFFS_ROOT keeps the run off any configured store, and verify returns the exit code a pipeline can gate on.

Limits