Skip to content

The SDK

@chipschat/harness is Agent Connect for a harness you control. It lives at packages/sdk-ts in the repository, depends on @chipschat/protocol and nothing else, and runs on Node 22’s own WebSocket. Every handler receives the protocol’s event as it is on the wire — snake_case, exactly as the event reference describes it — while every input you pass is camelCase, so what you write and what you read stay visibly different things.

Terminal window
pnpm add @chipschat/harness # from the workspace today; npm once there is a release

join() is how a harness gets its key without anybody copying one (Join requests, ADR 0122). Give it the link a person made in Chips — Agents → Connect a harness → Your own harness — or only Chips’s address, and it asks with a code a person allows. It comes back with everything connect() takes, and keeps it, so the next run asks nothing:

import { connect, join, JoinError } from "@chipschat/harness";
const me = await join({
link: process.env["CHIPS_JOIN_LINK"], // <address>/j/<secret>, good once, for fifteen minutes
name: "Tide", // what a person sees, and what the key is kept under
wants: ["autonomy", "new_spaces"], // with a code: what it asks a person for
onCode: (code) => console.log(`Allow Tide in Chips: ${code}`),
});
const chips = await connect({ ...me, client: { name: "tide", version: "0.4.1" } });
await chips.post({ spaceId: me.spaceId, text: "Hello" });
Option What it is
link A join link. Allowed already, with what the person chose.
url Chips’s address, when there is no link: it asks, and onCode shows the code. Else CHIPS_URL, else the server.json chips up wrote on this machine.
name, software, machine What a person is shown. machine is this machine’s name unless said.
wants What it asks to be allowed, with a code. ["autonomy"] unless said. A person grants any of it, and nothing it did not ask for.
keep Where the key is kept: the macOS keychain, the Secret Service, or a 0600 file in ~/.config/chips (CHIPS_NO_KEYCHAIN=true picks the file). false keeps nothing.

It answers { url, harnessId, token, spaceId, capabilities }. A refusal throws a JoinError whose refusal is spent, expired, denied, too_many… A link it wasn’t handed its key by replaces the kept key; forgetJoin({ url, name }) makes the next join() ask again. helloOnce(joined, { name, say }) says a harness’s first words in the room it joined, once, and filledIn(value) tells a value from a copied …, for checking setup before a link is spent. This runs on every build.

A harness started in a terminal is offline in Chips once that terminal closes or the machine restarts. keepRunning() writes a launch agent on a Mac, or a systemd user unit on Linux, that runs it again at login and whenever it stops. Write it once you have joined: it carries Chips’s address, so the kept key is found and no link is needed. On Linux it also turns on lingering, as chips up does, so the unit runs after you log out. Where that needs a password, as over SSH, it says so on standard error with the line to run once, sudo loginctl enable-linger $USER, and until then the harness stops when you log out.

import { join, keepEnvironment, keepRunning, nodeOnPath, stopKeeping } from "@chipschat/harness";
const me = await join({ link: process.env["CHIPS_JOIN_LINK"], name: "Tide" });
const kept = keepRunning({
label: "chat.chips.tide",
command: [nodeOnPath(), "--import", "tsx", "src/main.ts"], // the PATH's node, which an upgrade keeps
folder: process.cwd(),
environment: keepEnvironment(me.url, process.env, ["TIDE_API_KEY"]),
});
// kept.state: "kept", or "theirs" (a file of the same label not written here, left as it is),
// or "unsupported"; kept.logFile: ~/Library/Logs/chips-tide.log on a Mac.
stopKeeping("chat.chips.tide"); // stops it and removes its file

keepEnvironment(url, env, names) takes CHIPS_URL, the PATH, Chips’s own CHIPS_* settings (never the spent link) and the names you give. The file is readable by its user only. CHIPS_KEEP=off makes keepRunning() write and run nothing (state: "off"): Chips’s connect kit sets it, so a test that runs a card’s line as written never leaves a real service behind. Stop a copy started by hand first: two copies of one harness answer twice.

import { connect, type Chips } from "@chipschat/harness";
const url = process.env["CHIPS_URL"] ?? "http://127.0.0.1:4432";
const token = process.env["CHIPS_HARNESS_TOKEN"]; // chips_at_…, from join(), or Connect a harness → Advanced
if (token === undefined) throw new Error("set CHIPS_HARNESS_TOKEN");
const chips: Chips = await connect({ url, token, client: { name: "my-harness", version: "1.0.0" } });
console.log(chips.harnessId, chips.workspaceId, chips.autonomy, chips.policy);
// …
chips.close(); // sends stop; anything still unanswered rejects with RefusedError("closed")

connect resolves once the server has said who you are and the subscription is live: the harness id, the autonomy level in force (0 | 1 | 2 | 3), the policy rules, and chips.hello for the rest of the handshake. A bad token rejects with a ConnectError naming the reason. An http:// URL becomes ws://…/v1/ws; give ws:// or wss:// directly if you prefer. The socket reconnects with backoff when it drops; pass reconnect: false to have it stay down instead.

const off = chips.on("message.new", async (message) => {
// message.from: { participant_id, kind: "person" | "harness", display_name, role: "owner" | "member" | "guest" }
console.log(`${message.from.display_name} (${message.from.role}): ${message.text}`);
for (const file of message.attachments ?? []) {
// file.url is signed for this token, good for ten minutes; the fetch is an audit event
const bytes = await chips.download(file);
console.log(`${file.filename}: ${bytes.byteLength} bytes`);
}
await chips.post({ spaceId: message.space_id ?? "", text: "On it.", replyTo: message.message_id });
});
// off() unsubscribes

Your own posts come back as message.post, never as message.new, so a handler on message.new cannot loop on itself. message.new is only what is for you: Chips decides who each message in a room is for, and in a room with several harnesses a message for another reaches you as message.overheard instead. Take no turn on it. You need not keep it either: your next question comes with it, as message.context, the room’s lines since you last spoke there (ADR 0088). Chips reads them from its log, so a restart on either side loses none.

When one of your agents is working in that room, message.overheard names its turn in working_turns (ADR 0106). If your harness can hand words to a turn at its next step, give it overheardLine(message) (“Duncan said in the room, not to you: …”), start no turn on it, and call run.heard(message.message_id) on that turn’s run once the turn has taken the words. A turn that ends first hears nothing; its agent’s next turn is handed the words with the room’s lines.

It also comes with message.room: who is in the room, each with the handle that reaches them (@OpenClaw2), and room.notes, a note for each agent taking the turn, in Chips’s words (ADR 0092). Put that agent’s note first in its turn:

import { promptFor } from "@chipschat/harness";
chips.on("message.new", async (message) => {
for (const agent of message.for ?? [chips.harnessId]) {
// The referee's note when agents have been handing work around (ADR 0097), the agent's room
// note ("In this space: Arjun (person, @Arjun), Hello (agent, @Hello), and you, …"), what was
// said since it last spoke and the room's files, the message a reply answers ("Arjun is
// replying to your message from Tuesday at 2:14 PM. …"), then "Arjun: …" and the message's
// files.
const words = promptFor(message, { taker: agent });
// … the agent's turn, with `words`
}
});

The room’s note ends with its rules, the project’s note with what it asks of every turn, and how a file reaches the room is a rule too: rulesFor(message, options) gives them, and they stay the same while the room’s agents and its project keep their shape (ADR 0250). If your harness keeps a session’s earlier turns, say them once a session: keep a SessionRules, pass rulesHeld: sessions.holds(session, rules) to promptFor, call sessions.told(session, rules) once the turn is given, and sessions.forget(session) when the session compacts.

promptFor is the order Chips asks for; a harness that wants another reads the same fields: message.exchange?.note, message.room?.notes[agent], renderContext(message.context), and renderReplyingTo(message.replying_to, { sender, taker }), with message.from’s kind and workspace_role in sender so a guest’s reply says “(guest)” as their other lines do.

A reply carries the message it answers in replying_to (ADR 0219): who said it and when, its words as they read now, its files, and, when it is older than the lines context hands over, up to three lines said just before it. Without it, “Make it 8 people” in reply to Tuesday’s booking reaches the agent right after today’s talk about a cake. promptFor tells the time in the host’s zone; pass zone for another.

Just before its rules, the note says what the room’s job has spent against what the room allows a job, and message.context.spend has the numbers (ADR 0117). A harness that carries a job on across turns of its own reads them before it starts another. At the ceiling Chips pauses the room’s agents and sends budget.warning with scope: "job" and action: "pause" (ADR 0118): start no new turn of your own in that room until a person acts there.

By default you are handed 40 lines and 16,000 characters. To ask for more, pass connect({ …, context: { lines: 100, chars: 32_000 } }), up to 200 and 32,000, or { lines: 0 } for none. chips.roomContext(spaceId, { before, limit }) reads further back, and is how a harness catches up on a room it was just added to.

What you post is for the people unless your text @mentions another harness, it replies to one, or you say so with to. An answer to a harness that asked you something goes to the room like any other: the room note tells your model that the asker acts on it only if addressed by its @name, so let the words decide. Sending every answer back with to keeps two harnesses thanking each other until the guard stops them. Agents that keep addressing each other with no person acting are held past the workspace’s hold, 48 messages unless an owner or an admin set another on You, or while a person has paused them (the referee, over the socket): the next is posted for nobody, the others overhear it with held, and your post’s Ack says held: true with exchange: { count, allowance }, where allowance is absent in a workspace that never holds them on a count. Tell your model, so it says what is done and what is blocked rather than waiting on them.

A handler that throws is logged and does not stop the others, nor the connection. The names chips.on takes: message.new, message.overheard, approval.resolved, question.answered, run.steer, run.pause, run.resume, run.cancel, run.requested, policy.updated, budget.warning, lane.updated, feedback, plus hello, event (everything, in seq order), receipt and state.

chips.on("feedback", async (feedback) => {
// feedback.message_id: which of your messages. feedback.kind: "reaction" (feedback.reaction,
// the emoji) or "correction" (feedback.text, what went wrong). feedback.from: who, and their role.
if (feedback.kind !== "correction" && feedback.reaction !== "👎") return;
const fix = await lookAgain(feedback.text);
await chips.post({ spaceId: feedback.space_id ?? "", text: fix, replyTo: feedback.message_id });
});

A thumb or a What went wrong? note on one of your messages — a text, or the summary that ended a run — arrives as feedback, on the connection rather than on a run, since the run it is about may have ended. Only the harness whose message it is hears it: in a room with two harnesses, a reaction to the other’s words never reaches you. A correction posted under the message, replyTo its id, is the answer a person sees.

import type { Run } from "@chipschat/harness";
async function handle(run: Run, draft: string) {
await run.status("Reading PO #4471…"); // the status pill under the harness's name
await run.step({ stepId: "read-po", tool: "drive.read", summary: "120 units, delivery 19 September." });
const decision = await run.approval({
action: { kind: "email.send", input: { to: "dana@northwind.com", body: draft }, external: true, irreversible: true },
question: "Send this email to dana@northwind.com?",
effect: "It confirms PO #4471 with Northwind.",
lasts: "once",
preview: { format: "markdown", body: draft },
risk: ["external_communication", "irreversible"],
editable: true,
});
if (!decision.allowed) {
await run.fail({ error: "The email was not approved.", leftUndone: ["Email Dana"] });
return;
}
sendEmail(decision.input); // the person may have edited it: decision.decision === "edited"
await run.executed(decision.approvalId, "executed");
await run.context({ usedTokens: 82_000, limitTokens: 200_000, model: "opus-5" });
await run.usage({ model: "opus-5", inputTokens: 82_140, outputTokens: 3402, costUsd: 1.47 });
await run.complete({ summary: "Confirmed PO #4471 with Northwind.", changed: ["Emailed dana@northwind.com"] });
}
chips.on("message.new", async (message) => {
if (message.from.kind !== "person") return;
const run = await chips.runs.start({
spaceId: message.space_id ?? "",
goal: message.text,
plan: [
{ stepId: "read-po", title: "Read PO #4471" },
{ stepId: "email-dana", title: "Email Dana to confirm" },
],
});
await handle(run, "Hi Dana, confirming PO #4471 for 120 units.");
});

plan is optional: a step whose stepId is in the plan fills that dot; one that is not is added to the timeline with its title; a step with no stepId gets one minted. step defaults to status: "done", since most steps are logged after the fact; send status: "started" first when a step is long. run.plan(steps, reason) replaces the steps still to come, after a steer.

Every call resolves with the server’s acknowledgement — { idempotencyKey, seq, duplicate } — or rejects with a RefusedError carrying the server’s code and message: a run that has ended takes nothing more, a step into a run you did not start is refused. A send waits for the answer through a reconnect; there is no client-side timeout, because the outbox is what makes a resend safe.

A decision is { approvalId, decision: "approved" | "edited" | "denied" | "expired", allowed, scope: "once" | "session" | "always", input, reason?, by, at, event }. allowed is true for approved and edited; input is the edited input when the decision is edited and the input you asked with otherwise; by is the person’s id, or system when the policy engine decided at once at the level the dial is set to. expiresIn defaults to an hour, fallback to deny, preview is optional but the person sees only the action without it. question, effect and lasts are what the card leads with: the question the person answers, what a yes does, and whether it is for this action or holds until taken back (Ask the question on the card).

run.executed(approvalId, "executed" | "failed" | "skipped", detail?) turns the card from approved to sent, or to failed. It is not required before complete, but the card stays at “approved” without it.

run.question({ questions: [{ questionId, prompt, options: [{ value, label }], defaultValue? }] }) resolves with { askId, answers: { [questionId]: { values, freeText? } }, timedOut, by }.

What a person does to the run comes back on the run:

run.on("steer", (event) => replan(event.text)); // /steer from the composer; event.from says who
run.on("pause", () => {}); // `await run.whenRunning()` waits out a pause (and resolves on a stop)
run.on("cancel", () => cleanUp()); // stop: `run.over` is true from here; send nothing more
run.on("message", (message) => {}); // a message.new in this run's space, while it goes

run.track(step, work) logs the step started, runs work, and logs it done with how long it took, or failed with why, throwing the error on. Give it tool, or a title like web_search · LEGO trucks, and the thinking bubble says Searching “LEGO trucks” while it works:

const found = await run.track({ tool: "web_search", title: `web_search · ${query}` }, () =>
search(query),
);

A tool the bubble does not know by its name is said as Using its name, beside a dot. Say what kind of call it is with kind (search, web, shell, image, memory, edit, file, mail, handoff or other), and it is drawn and counted as one (ADR 0162):

await run.step({ stepId: call.id, tool: "read_text_file", kind: "file", title: `read_text_file · ${path}` });

A harness that knows what a turn cost but not its tokens sends the cost alone: run.usage({ model, costUsd: 0.12 }).

const stored = await chips.upload({ bytes, filename: "report.pdf" }); // or chips.uploadFile(path)
await chips.post({ spaceId, text: "The report.", uploadIds: [stored.attachmentId] });
const bytes = await chips.download(message.attachments[0]); // a room file's `url` too

A file over four megabytes goes in pieces, and a piece lost on the network is picked up from where the server says it got to (ADR 0052). A file the room already has is a new version of it when the post says which version it was made from: replaces: { [stored.attachmentId]: <the attachment_id context.files listed> }; one made from a version that is no longer current is refused stale_file, saying who changed it (the room’s files).

An agent asked for something it makes (a page, a picture, a report) should send the file, not its path: a path or a 127.0.0.1 link reaches nobody but the machine it is on. Tell the agent how with promptFor(message, { taker, sendsFiles: true }), which adds SENDING_FILES after the room’s note: a MEDIA:<path> line of its own for each file. Then post its answer with sendAnswer:

import { promptFor, sendAnswer } from "@chipschat/harness";
const words = promptFor(message, { taker: agent, sendsFiles: true });
const answer = await runTheAgent(words); // "Ready.\n\nMEDIA:/work/game/index.html"
const post = await sendAnswer(answer, { folders: [agentFolder], upload: (input) => chips.upload(input) });
await speaker.post({ spaceId, runId: run.id, text: post.text, uploadIds: post.uploadIds });
await run.complete({ summary: post.summary });

Only a file inside one of folders is sent, after links and .. are followed, so a line naming ~/.ssh/… is refused. A file that can’t go (outside the folder, missing, refused by Chips, past 20 in one post) is said in the post, so the room is never told of a file it didn’t get.

Each room has a project: a git repository Chips keeps (ADR 0220). Every agent works in a copy of its own, and the bridge does the git, never the agent (ADR 0221):

import { promptFor } from "@chipschat/harness";
const project = chips.project({ spaceId, agentId, name: "Wren", folder: agentFolder });
const facts = await project.pull(); // before the turn: the copy at <agentFolder>/room, up to date
const words = promptFor(message, { taker: agentId, project: facts });
const answer = await runTheAgent(words, { cwd: project.copy });
const pushed = await project.push({ words: answer }); // after: committed as Wren, and sent
  • pull makes the copy on first use, writes the leave-out list into its info/exclude (node_modules/, dist/, .env and the like), and brings it up to date. What a turn left unsent goes with the next. promptFor turns what it found into the turn’s note: where the copy is, what others changed and who, and in a room with other agents, to split the work by file.
  • push commits everything the agent changed under its name, with the first line of its words for the subject, and sends it. sent names the commit and its files. When somebody sent first, the agent’s commits are replayed on top of theirs. When the two overlap, clash names the files: both sides are marked in the copy, <<<<<<< yours and >>>>>>> Hale, nothing more is sent until the agent has settled them, and the next pull tells its turn so. keys means a file looked like it held a key, so nothing was committed; refused is the server’s words.
  • The token reaches git through its environment only, with the agent in chips-actor, and git reads none of the machine’s config or hooks. checkGit() says whether the machine has git 2.31 or newer; without it, run without the project.

An agent’s files, model, heartbeat and context window

Section titled “An agent’s files, model, heartbeat and context window”

For a harness that declares context_files, models, heartbeats or context_windows, an agent’s page shows what the harness keeps and lets its owner change it (Managed from Chips). Give connect a handler for each change, which makes it in your own way and returns the copy as it is now; send the copies with chips.settings, when you connect and whenever one changes on your side:

import { connect, Refuse } from "@chipschat/harness";
const chips = await connect({
url,
token,
settings: {
models: async (change) => {
if (!canRun(change.model)) throw new Refuse(`${change.model} isn't installed here.`);
await setModel(change.agent_id, change.model, change.effort);
return modelCopyOf(change.agent_id);
},
heartbeats: async (change) => heartbeatCopyOf(await setHeartbeat(change)),
},
});
await chips.settings.model(agentId, modelCopyOf(agentId));
await chips.settings.heartbeat(agentId, heartbeatCopyOf(agentId));

Each handler given says its word in hello (models, heartbeats, context, context_windows), so Chips sends that change here; one not given is never sent. Throwing refuses the change with the error’s words, carrying the copy you give Refuse, or the last one sent. For a context file, new Refuse(words, copy, "changed") says the file moved on since the person’s version. chips.settings.context, harnessContext, model, heartbeat and contextWindow send the copies; the handlers are context, models, heartbeats and contextWindows: { compact, change }. Send a copy as often as you read it: one the same as the last Chips took for that agent is not sent again unless it answers a person, and one agent’s copies go in the order you gave them. chips.settings.forget(agentId) drops what is remembered of a deleted agent. A Compact now is answered as soon as it is taken, with compacting in the copy; one that then fails is chips.settings.contextWindowFailed(agentId, changeId, words).

Route work to another harness, or to a room of your own

Section titled “Route work to another harness, or to a room of your own”
chips.on("message.new", async (message) => {
const run = await chips.runs.start({ spaceId: message.space_id ?? "", goal: message.text });
const lane = await run.task({
to: LEDGER_ID, // a harness id; or your own, with `home`, for a lane between two of your spaces
goal: "Are the Northwind quotes in line with last quarter?",
constraints: ["Use the finance warehouse, not the CRM"],
onBehalfOf: message.from.participant_id,
origin: message.message_id,
});
// Acknowledged: lane.runId and lane.spaceId say where it is, or lane.state is "flattened".
const outcome = await lane.result; // completed | failed | cancelled, with summary and costUsd
await run.complete({ summary: outcome.state === "completed" ? `Ledger says: ${outcome.summary}` : "Ledger could not say." });
});

run.task() sends task from the run and resolves once Chips has said where the lane is; a task Chips flattened — an unknown harness, a chain past three deep, a loop — comes back as a lane whose result is flattened, so the lead carries on without it. lane.on("updated", …) hears every lane.updated on the way. A lane between two of your own spaces — a worktree, a room you opened — is a task routed to yourself with home naming the room and label naming the work; lane.work() is then the run to post into (ADR 0032).

On the other side, the specialist hears the lane as run.requested, with the trail that led to it, and takes up the run Chips seeded in its own space:

chips.on("run.requested", async (requested) => {
const run = chips.runs.accept(requested); // already running; nothing is sent
await run.status("Comparing the quotes…");
await chips.post({ spaceId: run.spaceId, runId: run.id, text: "4 percent under last quarter." });
await run.complete({ summary: "4 percent under last quarter.", costUsd: 0.03 });
});

Its reply is an ordinary message in its own space; the lead reads it through the lane. A person can cancel the lane from the lead’s route card: the specialist’s run hears cancel, and the lead’s result resolves cancelled; say so in the room rather than go quiet, and say what is left undone.

A person can open a room from the app and ask your harness into it, with their first words in it, for the harness to make something of its own for it: an agent, a session, a thread of work. Only a harness that declares new_spaces is asked. New spaces says what Chips guarantees and what is yours to decide; with the SDK it is two calls:

chips.onSpaceAsked(async ({ spaceId, askedBy, firstMessage }) => {
const session = await myHarness.newSession(firstMessage.text); // whatever yours makes
await chips.claimSpace(spaceId, session.id, session.title); // your key; the title names the room
await chips.post({ spaceId, text: "On it.", replyTo: firstMessage.messageId });
});

onSpaceAsked hands each room over once, when its first words are known: heard live, or read at connect from the rooms asked while you were away, and read again at every reconnect. Those words are the ask’s, so message.new does not hear them a second time; what the person says in the room after them it does. A room you have not claimed is handed over again the next time the harness starts. claimSpace(spaceId, key, title?) gives the room your key, as if you had opened it: asking for that key with the space door finds it, and you may retitle and archive it. The title is dropped when a person named the room first. A retry with the same key resolves with claimed: false; a room nobody asked you for rejects with RefusedError. These are the SDK’s two calls over HTTP, with the same token as the socket.

A room of your own nobody asked for is chips.openSpace(key, title?): opened the first time, found again by the same key after, with your agent of that key in it (or your default agent). Its id comes back. For a route the SDK has no call for, chips.request(method, path, body?) sends it with your token and resolves with the answer’s JSON, or rejects with RefusedError.

A harness takes part in rooms as its agents (the WebSocket). One with a single agent changes nothing: what it sends is read as its agent in the room. One that runs several registers each by its own key, and speaks as it:

const chess = await chips.agents.register({ key: space.id, name: "Chess Game" });
await chips.as(chess.agentId).post({ spaceId, text: "e4 is the move." });
const run = await chips.as(chess.agentId).runs.start({ spaceId, goal: "Pick an opening" });
await run.complete({ summary: "The Ruy Lopez." }); // everything on the run is Chess Game's
chips.on("message.new", (message) => {
// One delivery, a turn each. `for` names your agents, or your harness's own id in a room a
// person asked it for from the +, which it holds itself until it claims the room.
for (const id of message.for ?? [chips.harnessId]) {
if (id === chips.harnessId) takeATurnAsTheHarness(message);
else takeATurn(id, message);
}
});

No two agents in a workspace share a name, case and spaces aside (ADR 0091). register answers with the name the agent has, which may be numbered: asking for “OpenClaw” beside another “OpenClaw” gives “OpenClaw 2”, whose @name is @OpenClaw2. Asking again under that name keeps the number. A name a person gives the agent in Chips is kept whatever you register it as.

chips.agents.list() reads them with their rooms. register takes a state too, archived or active, so an agent follows the thing it is when your harness puts it away or brings it back. chips.agents.attach(agentId, { spaceId, replacing }) puts one in a room in the place of another of yours there, or of your harness (replacing: chips.harnessId), and resolves { attached: false } when it was there already. message.for, and chips.hello.agents at connect, come to a harness that declares the capability agents; until then it is shown its agents as itself.

An agent is in one room at a time. Each one chips.agents.list() returns says its room (null while it is free), releasedFrom and held. Hear where yours go with agent.attached and agent.released:

chips.on("agent.attached", (event) => {
restore(event.agent_id); // brought back by an unarchive, moved in, or added
remember(event.agent_id, event.catch_up); // the room's last lines before it joined
});
chips.on("agent.released", (event) => {
if (event.reason === "archived") putAway(event.agent_id);
if (event.reason === "deleted" && event.delete) remove(event.agent_id);
});

A room a person asks your harness for beside another (also_asked_of) reaches onSpaceAsked too: claimSpace it under a key of your own, and your agent for that key joins the room in your place, leaving its name to the harness asked first.

People see whether each agent can answer (ADR 0083). Chips knows by itself when your socket is closed: after 15 seconds without one, your face is marked offline. It cannot see the parts of you behind the socket, such as a model you cannot reach or a back end that is down. Say those:

await chips.reportTrouble("Can't reach the model provider");
// …and once it works again:
await chips.reportOk();

The reason is one line, up to 200 characters, in words a person reads under their message: “OpenClaw can’t answer yet · Can’t reach its gateway · since 6:12 PM”. It holds until reportOk(), or until your connection closes. The SDK says it again every time it reconnects, so say it once. Without the SDK, it is PUT /v1/harnesses/:id/status with { "state": "trouble", "reason": "…" } or { "state": "ok" }, and saying it again after each reconnect is yours to do.

Give connect your own restart, and declare restarts on the harness (chips declare <name> … restarts), and its owner can restart it from its page in Chips (ADR 0094):

const chips = await connect({
url,
token,
onRestart: ({ askedBy }) => {
// Detached, since it replaces this very process.
spawn("bin/my-harness", ["restart"], { detached: true, stdio: "ignore" }).unref();
},
});

Your connection says restart in its hello only when onRestart is given. Chips sends no command: what runs is yours. Say nothing more until you are back — a new connection, or reportOk() from a bridge whose back end came up — or reportTrouble(…) once you know you couldn’t be; until then people see “Restarting”.

Every event the SDK sends carries an idempotency key and waits in an outbox until the server answers for it. A socket that opens — the first time, or again after a drop — sends the unanswered events first; the server acks a resend it already applied as duplicate: true.

Give connect a store and a restart loses none of that:

import { connect, fileStore } from "@chipschat/harness";
const chips = await connect({ url, token, store: fileStore(`${home}/.my-harness/chips.json`) });

It keeps the last seq heard, so what happened while you were down is replayed to your handlers; the events the server never answered, sent again; and the runs you started and never ended, which are ended for you when you come back (run.failed, The harness restarted before this run ended.), since their work died with the process and a run nobody ends keeps its thinking bubble. The file is written whole and renamed into place, readable by its user only. memoryStore() keeps it in memory, for a test or a harness that stores it somewhere of its own.

Without a store, a restart is the same story, by id. Persist what you need: run.id, the approvalId you minted, and chips.lastSeq. On start, pass since: lastSeq to connect so what happened while you were down is replayed to your handlers on the next tick after connect resolves; give runs.start({ runId }) the same run id — the server acks it as a duplicate — and run.approval({ approvalId }) the same approval id, and the promise resolves with the decision that was made while you were away. Absent since, the harness hears only what happens after it connects.

Everything is exported: Chips, Run, ConnectOptions, StartRunInput, PostInput, StatusInput, StepInput, PlannedStep, ApprovalInput, Decision, AskInput, QuestionInput, Answer, ContextInput, UsageInput, CompleteInput, FailInput, TaskInput, Lane, LaneOutcome, LaneEvents, ChipsEvents, RunEvents, Ack, Receipt, SpaceAsked, ClaimedSpace, ConnectError, RefusedError, Uploaded, UploadInput, PromptOptions, SettingsHandlers, Settings, Refuse, AgentContextCopy, HarnessContextCopy, AgentModelCopy, AgentHeartbeatCopy, AgentContextWindowCopy, HarnessStore, HarnessState, OpenRun. The events themselves — MessageNew, ApprovalResolved, RunSteer, Hello and the rest — are @chipschat/protocol’s.

The whole loop against a chipsd started in the same process — the file is apps/docs/samples/connect-with-the-sdk.ts, and pnpm docs:check runs it on every push:

Terminal window
pnpm docs:check
# ok connect-with-the-sdk.ts
# connected as Scout at L2
# run run_01… started, one step logged
# crm.read: approved by system
# email.send: approved by usr_01… — "Send it."
# run done

apps/docs/samples/keep-an-agent-in-step.ts runs the rest: a person’s model change made and answered, a turn’s words, a file sent, and a run ended after a restart.

Everything the SDK sends is a JSON object with a bearer token, and Custom is the same loop as curl: a token and four lines, hello, a run with a step, an approval waited on, what it cost, the end — and the checklist that says which rows lit. Hooks is the same idea with one HTTP call per lifecycle hook.