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-cli and spore setup run 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-api running as a service, and a caller for your application in ~/.config/spore/api.json. Its token goes in ~/.config/spore/environment as SPORE_API_APP, never in the file, and spores names 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/spore in 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", delta is the answer's text as it is written
complete
output, the answer, as soon as it exists: show it. It is not final, and its usage counts no cell
deliverables
files, each { path, bytes, mtime, mime }, what the turn wrote under data/out/, and more
turn_end
turnNo, 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 output of the complete event with the same turnId, also last.output in GET /v1/spores/review-acme while 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 the usage of complete
  • how it ended: stopReason is answer; question, where output is 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; or error
  • the report: the deliverables paths, 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 admissioncaller admission
who bounds the turns running at oncespore, with turns slots (8 unless set)your program
more turns than thatADMISSION_FULLnever refused for that
what cannot start yetheld, and released on a spore tick passnothing is held: a turn starts now, or you get a refusal to retry
fitsa machine where many programs start turnsone 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 seq it handled for each, and the inbox of 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 hop says 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 withruns wherebest for
the commandspore send … --json, from any language, or over sshon the spores' hostscripts, CI, another machine over ssh
the library@rhizogrip/sporein your Node process, on the spores' hosta long-lived service in front of many spores
the HTTP APIspore-api, on a unix socket or a loopback portanywhere that can reach the socket or a proxy in front of ita 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.