build with spore
Your product has users, a workflow and an interface. You want an agent to take on part of the work — review a customer's contracts, say — and to come back to it next month with the programs it wrote. This page carries one such task from an empty workspace to a finished result and its settled cost, over the HTTP API, with the same steps from Node at the end. Every request and answer here is as INTEGRATION.md in the spore repository documents it; that file is the full contract.
before you start
- A Linux host that meets the requirements, with
npm install -g @rhizogrip/spore-cliandspore setuprun on it. Setup sets up a model provider and the model new spores run on, and keeps the keys where no agent reads them. - For HTTP:
spore-apirunning as a service, and a caller for your application in~/.config/spore/api.json. Its token goes in~/.config/spore/environmentasSPORE_API_APP, never in the file, andsporesnames the spores this caller may reach.spore service define api && spore service enable api && spore service start api{ "socket": "/run/spore/api.sock", "callers": { "app": { "token": "${SPORE_API_APP}", "spores": ["review-*"] } } } - For Node:
npm install @rhizogrip/sporein a service on the spores' host, run as the Unix user spore runs as.
How to run the service, and every key of api.json, is in docs/operating.md.
1 define the agent
The agent reviews one customer's contracts. What it is — its model, its tools, its limits and, in a few lines, its job — is one short file, its definition:
review.cmk:
---
name: review
description: Reviews contract batches for one customer
model: anthropic/claude-sonnet-5-5
tools: [read, write, edit, exec, wsexec, ask, quiet]
rounds: [60, 70]
cost: [2.00, 3.00]
wall: [20m, 25m]
---
You review contracts for one customer, against the rules sent with each batch.
When a check repeats across documents, write a program for it, test it, and
keep it in data/ for the next batch. Put the report in data/out/. Each limit is a pair, soft then hard: rounds counts model calls, cost dollars, wall elapsed time, all per turn — the agent's work on
one message. Past the soft one the agent is asked to answer with what it has. Once settled spend
reaches the hard cost, no new model call starts; one already running finishes and is paid, so a
turn can end a little past it. The cost covers the cells the agent creates — specialists made on
the spot for one narrow job. The full format is docs/definition.md.
2 create its workspace
A new customer, Acme, signs up. Your application makes their spore from the definition, on the host:
spore define review-acme --definition review.cmk or from Node:
import { define } from '@rhizogrip/spore'
define({ ws: 'review-acme', definition: 'review.cmk' }) The spore is its workspace: a folder, review-acme, under the host's spore home. It
keeps the definition, every message, every turn and how it ended, the events, and what the agent
made in data/, from one turn to the next. A second customer gets review-globex. Defining again with --definition revises the definition
and keeps the conversation and data/.
A workspace per customer keeps their agents apart. It does not sign your customers in: the API token names your application, not your user, so which user may reach which spore is your application's to decide.
3 submit work with a request ID
Before you send, find where the spore's event log ends. Every event carries seq, a
count that only increases; with none saved yet, read once without following and keep the last seq:
curl --unix-socket /run/spore/api.sock -H "Authorization: Bearer $T" \
"http://spore/v1/spores/review-acme/events?after=0&follow=false" A reviewer at Acme uploads a batch in your application and asks for a review. Their request goes to the spore as a message, under a request ID of your own:
curl --unix-socket /run/spore/api.sock -H "Authorization: Bearer $T" \
http://spore/v1/spores/review-acme/messages -d '{
"text": "Review this batch against rules.md.",
"requestId": "batch-2207",
"attach": [
{ "name": "rules.md", "mime": "text/markdown", "data": "<base64>" },
{ "name": "msa-0412.pdf", "mime": "application/pdf", "data": "<base64>" }
]
}'
# {"inbox":12,"started":true,"repeated":false} The answer is 202. Keep inbox, your message's position in the spore's
inbox: it is how you find the turn that answered it. started is true when this
message started a turn, and false when it joined one already running, which reads it between
rounds. Under spore admission (below), a 503 ADMISSION_FULL means every
turn slot is taken; it can be retried.
Sent again under the same requestId, the message is taken once: the answer gives
the first message's inbox with repeated: true, and starts nothing if a
turn has already read it. That makes the submission once-only, not every side effect. A turn
that fails leaves its message unread for the next turn, which may do again what the first did
before it failed: key your own side effects — the reply you post, the record you write — on the
turn's turnId, so that doing one twice is safe.
Work your program hands over on its own, with no person asking — a nightly batch, say — goes to POST /v1/spores/<name>/deliveries instead, under a delivery id taken
once in the same way; its answer goes where the spore's default reply-to says.
4 follow events
curl --unix-socket /run/spore/api.sock -H "Authorization: Bearer $T" \
"http://spore/v1/spores/review-acme/events?after=$LAST_SEQ" The answer is NDJSON, one JSON event per line: every event after seq, then each new
one until the turn ends. Every event carries v, seq, at, runId, turnId and event, its name. Save seq once you have handled the line. Four matter here:
stream- with
kind: "message",deltais the answer's text as it is written completeoutput, the answer, as soon as it exists: show it. It is not final, and itsusagecounts no celldeliverablesfiles, each{ path, bytes, mtime, mime }, what the turn wrote underdata/out/, andmoreturn_endturnNo,stopReason,concluded,error,rounds,ownUsd,treeUsd,workUnits,settled,inboxFrom,inboxTo: written by spore's supervisor as the turn is made final
Ignore events and fields you do not know: fields are only added, and names never change meaning. tool_start and tool_end carry the agent's arguments — paths, queries, command
lines — so filter them before they leave the machine.
5 reconnect from a cursor
Your process restarts mid-review. The turn does not notice: it runs detached from whoever
started it. Open events again from the last seq you saved, and you get
every event after it. If the stream ends without the turn_end that answers you,
open it again from your last seq: the spore reads your message in its next turn.
Unsure whether the submission itself got through? Send it again under the same requestId.
6 read the result and its finalized cost
The turn that answered you is the one whose turn_end has inboxFrom < inbox <= inboxTo. From it:
- the answer: the
outputof thecompleteevent with the sameturnId, alsolast.outputinGET /v1/spores/review-acmewhile that turn is the spore's last - the cost:
treeUsd, the turn's spend with its cells', as settled;ownUsd, the agent's own;settled, whether a ledger counted them. This is what to bill and count, never theusageofcomplete - how it ended:
stopReasonisanswer;question, whereoutputis the agent's question and your next message answers it;quiet;soft:<dimension>, answered at a soft limit, where sending again carries on;hard:<dimension>;cancelled; orerror - the report: the
deliverablespaths, relative to the workspace. spore hands over paths and types, never bytes; collecting the files is your application's
Events are pruned with retention. The turn's record keeps the same final facts — from Node, readTurns(ws) or turnOfMessage — and agrees with turn_end.
who admits turns
By default, spore admits turns: it bounds how many of the user's turns run at once, because on a
machine where chat services, webhooks and schedules all start turns, nothing else knows the
total. A product with its own scheduler can control turn admission instead, set once for every
spore of the user in ~/.config/spore/config.json:
"admission": "caller" | spore admission | caller admission | |
|---|---|---|
| who bounds the turns running at once | spore, with turns slots (8 unless set) | your program |
| more turns than that | ADMISSION_FULL | never refused for that |
| what cannot start yet | held, and released on a spore tick pass | nothing is held: a turn starts now, or you get a refusal to retry |
| fits | a machine where many programs start turns | one program starts every turn |
Under caller admission your program owns the bound: how many turns it admits must fit the machine's memory and CPU, and nothing in spore stops it admitting more. A pause holds in both modes.
what your application owns
your application
- its interface, its users and how they sign in
- its business rules, and when work is sent
- which spore is whose, the last
seqit handled for each, and theinboxof each message it sent — nothing else about a spore - retries: spore never retries a refused submission or a failed turn
- its own side effects, made safe to repeat
- collecting what a turn hands back, and filtering events before they leave the machine
- where spores wake each other through it, where that chain stops: a delivery's
hopsays how many deliveries led to it, and your program decides - asking named people for an approval when an action needs one: it counts only as a press of a button spore's chat service posted, and the agent can read how it ended but cannot make, answer or change one
spore
- running each turn — the agent, its cells, its model calls — apart from your process
- the workspace: definition, messages, turns, transcript, events and files
- confinement, the keys model calls need, and the limits in the definition
- each turn's record: how it ended and what it spent
- under spore admission, how many turns run at once
A service that starts turns is their parent process. Under systemd it runs with KillMode=process, so restarting it does not kill the turns it started.
three ways in
The command, the Node library and the HTTP API call the same functions: they refuse the same things, and work started through one continues through another.
| reach it with | runs where | best for | |
|---|---|---|---|
| the command | spore send … --json, from any language, or over ssh | on the spores' host | scripts, CI, another machine over ssh |
| the library | @rhizogrip/spore | in your Node process, on the spores' host | a long-lived service in front of many spores |
| the HTTP API | spore-api, on a unix socket or a loopback port | anywhere that can reach the socket or a proxy in front of it | a caller in a container or on another machine |
the same path from Node
Steps 3 to 6 in one process: send under a request ID, follow from the last seq, and
read the record of the turn that answered. A service following many spores uses watchAll, saving one cursor per spore once what it covers is handled.
import { readFileSync } from 'node:fs'
import { isWorkspace, lastEventSeq, loadEnvironment, readInbox, RUNNING, send, stateOf, turnOfMessage, watch, workspaceFor } from '@rhizogrip/spore'
loadEnvironment()
const ws = workspaceFor('review-acme').path
if (!isWorkspace(ws)) throw new Error('no spore called review-acme')
let seq = lastEventSeq(ws)
const { inbox } = send(ws, 'Review this batch against rules.md.', {
requestId: 'batch-2207',
attach: [{ name: 'rules.md', mime: 'text/markdown', data: readFileSync('rules.md') }]
})
let found = null
while (!found) {
for await (const event of watch(ws, { after: seq })) {
seq = event.seq
if (event.event === 'stream' && event.kind === 'message') process.stdout.write(event.delta)
}
const [entry] = readInbox(ws, { after: inbox - 1, upto: inbox })
found = turnOfMessage(ws, entry)
if (!found && stateOf(ws).state !== RUNNING) break
}
if (found?.read) console.log(found.turn.stopReason, found.turn.treeUsd, found.turn.output) Every export, with its errors, is in docs/library.md; the whole contract in INTEGRATION.md. What holds the agent while it works is on security.