Skip to content

The data plane

fountain reaches the sandboxes its agents run in through one credential and one base URL. dataPlane=spritzer points that URL at spritzer, an emulator of the Sprites API running in the cluster.

The seam is pure configuration. The app is not built differently and cannot tell the difference, so a local run exercises the real control-plane path instead of a stub of it.

targetdefault
k3dspritzer. Offline there is no Sprites account, and a placeholder token against the real API is a 401 nobody sees until they talk to an agent
kubernetessprites

Creating a conversation creates a sprite and populates it. fountain writes its fountain skill and a /home/sprite/.env carrying a scoped token and the conversation id into the sprite’s filesystem, and spritzer then reports that sprite running with both files present.

At fountain v0.7.0 and spritzer 0.5.0, the turn gate holds. The “every time” evidence below is v0.6.1’s — 34 of 34, batched — and v0.7.0 has been through the single conversation just e2e runs, not a rerun of that batch; nothing in v0.7.0 touches this path. fountain opens an exec session for the runtime command and writes the prompt into it as stdin; spritzer holds the session open, echoes the prompt back on stdout, and exits 0 on EOF:

event: output claude --dangerously-skip-permissions --print --verbose --output-format stream-json …
event: output Reply with the single word: fountain
event: stage turn done {"exit_code":0}

That second line is the prompt making the round trip. It is the difference between provisioning a sandbox and holding a conversation — and it is still the echo, never a model. See what it will never prove.

Measured: 34 of 34 conversations, including batches fired back to back, which is the pacing that used to fail most.

So the emulated data plane proves the substrate can provision a sandbox, address it, and carry a prompt into it and a reply back out.

Even once that endpoint lands, three things are absent and no configuration brings them back:

  • live model reasoning
  • real tool execution in the sandbox
  • true VM isolation

spritzer answers exec with a scripted interpreter whose default is to echo the command back. A green local conversation is a plumbing check. It is not somewhere to judge agent behaviour or sandbox security, and single-node Postgres is likewise not somewhere to benchmark durability.

POST /api/auth/register registers the same way the form does — under this deployment’s defaults the account self-verifies at registration, and the instance’s first account becomes the admin (fountain ADR 0011) — which is what a script, a CI step or an agent needs:

Terminal window
curl -sX POST http://localhost:4000/api/auth/register \
-H 'content-type: application/json' \
-d '{"email":"you@example.com","password":"..."}'
{"message":"Account created. You can sign in now.","user_id":"60e3c0e6-..."}

An API key comes from the same shape of call:

Terminal window
curl -sX POST http://localhost:4000/api/auth/token \
-H 'content-type: application/json' \
-d '{"email":"you@example.com","password":"..."}'
{"prefix":"ftn_75cd","key_id":"39a7161a-...","api_key":"ftn_75cda1c2..."}

That key is what the conversation gate below authenticates with, so the whole path from nothing to a running conversation is reachable without opening a browser — and without a single kubectl command.

just verify asks /health. A 200 there says the release booted; it does not say it reached its database, resolved its secrets, provisioned a sandbox or streamed anything back. just verify-conversation checks the rest:

Terminal window
export FOUNTAIN_PASSWORD=... # not on the command line
just verify-conversation you@example.com # plumbing
just verify-conversation you@example.com strict # plumbing + a model replied

Both make a throwaway agent, open one conversation, and tear both down on the way out, including when an assertion fails, which is the case that matters.

plumbing asserts a sandbox was provisioned, a turn ran, events streamed in order, and the turn exited 0. It catches a broken Secret, an unreachable data plane, a migration that did not run.

strict additionally asserts a model replied, and refuses to run against dataPlane=spritzer:

✗ strict needs a real data plane. This deployment runs the emulator,
which echoes the runtime command back instead of calling a model,
so a green run here would prove nothing about a reply.

The emulator satisfies every plumbing assertion with no model in the loop at all, so a gate that cannot tell those apart is worse than no gate. This one fails closed.

Against the local default, plumbing passes: the turn completes and the prompt comes back. An orphaned turn or a :command_exited fails the build now — both were outcomes once and are regressions today.

Set dataPlane=sprites and put a real SPRITES_TOKEN in the Secret.

Note also that a Fly platform token is not a Sprites token. A valid Fly credential that api.fly.io/graphql accepts is rejected by api.sprites.dev with 401 authentication failed.