Managoat is the hosted Fountain. Fountain is the open-source engine, and its name is on the CLI, the API, the SDK and this manual. Everything here applies to Managoat unless a page says it is for a self-hosted server.
Add a sandbox provider
Fountain runs a conversation on a pluggable sandbox backend, behind the
Fountain.Sandbox behaviour. Today those are Sprites, E2B, Daytona and the
self-hosted runner. This page is the practical checklist for another one.
Three places hold the contract itself.
ADR 0018
holds the why behind the shape of the seam. The Fountain.Sandbox moduledoc
is the contract. Fountain.SandboxConformanceCase is the executable form of
it. When one of those disagrees with this page, it wins.
For the same territory from the platform's side of the table, read what we need from a platform. It covers what we would ask a vendor to support natively, and what each gap costs us.
Before you write any code, answer six questions
Each hard defect in the E2B and Daytona adapters came from one of these. Answer them against the provider's real API. Run the calls, and do not trust the docs.
Both adapters shipped with research that somebody verified against the official specs. That research was still wrong on five counts once we measured it live.
- Identity. Can you create a sandbox with a name you choose, then fetch
it by that name? Daytona can. E2B cannot, so the adapter emulates a name
with metadata filters and adopt-on-list. Fountain always mints the name,
as
fountain-<prefix>-<hex>. Your adapter must make create idempotent, so that a create against a name that exists adopts it. Yourgetmust tell{:error, :not_found}apart from a transient failure, because that is what protects a parked disk from a teardown on one flaky network call. - Suspend. Is there a park state that keeps the disk at a lower cost,
and a resume that returns the same disk? The agent's memory lives on the
sandbox filesystem, so a resume with a fresh disk loses data. It is not a
degradation. If the provider cannot do this, do not fake it. Omit the
:suspendcapability, andLifecycle.idle_action/1destroys on idle instead. Honest amnesia beats amnesia that says nothing. - Streams. How do you get the stdout and stderr of a long process, live?
And, above all, can a client that reconnects replay the output from byte
zero? The attach contract demands replay from the start. E2B's daemon
cannot replay, so the adapter uses a
teeshim that journals, and a tail replayer. Daytona's follow websocket turned out to be unusable live, so the adapter polls the HTTP journal. Expect to build something here. Measure the vendor's stream endpoint with raw bytes before you trust it. - stdin. Can you write to a live process's stdin again and again, and
send a real EOF? An ACP turn holds stdin open for the whole turn and
writes NDJSON into it. Daytona's input endpoint sent EOF to the pipe after
each write, so the adapter routes stdin through a file that
tailfeeds. Also,write_stdinmust be total. A write to a command that exited returns{:error, :command_exited}, and never crashes the caller. - Exit codes. Does the provider report the exit code of a spawned command reliably? Daytona does not, so the spawn shim writes an exit sentinel file. Never invent an exit 0. A stream that closes with no verdict is exit 0 only when the contract's close-without-exit rule applies.
- Egress. Is there a deny-by-default network mode with a domain
allowlist you can update at runtime? If so, advertise
:network_policy, and remember thatallow: []means deny all, and never a no-op. If the tier gates it, as Daytona's does, document the failure. Alimitednetwork environment then fails to provision on that provider.
One note on a provider we rejected. The original campaign passed over Modal, because its control plane is gRPC alone, and everything else here is plain HTTP with Req. Revisit that only if it changed, or if you are ready to take a gRPC dependency.
The code checklist
Registration centers on apps/fountain/lib/fountain/sandbox.ex. The changeset
validations and the schema-enum guardrail derive from known_providers/0.
Three lists are still literals, and the guardrail is what catches the drift.
Those are the sandbox_provider enums in fountain_web/schemas.ex, on Agent,
AgentCreate and AgentUpdate, and the SANDBOX_PROVIDER boot check in
config/runtime.exs. Budget for those edits.
- Adapter modules, under
apps/fountain/lib/fountain/sandbox/<provider>/. You need the adapter, with@behaviour Fountain.Sandbox, an API client, and whatever stream or command-server processes the provider needs. Keep the provider's secrets out of an inspection ofHandle.private. The struct already excludesprivatefrom Inspect, so do not defeat that. Normalize each error into the taxonomy in the behaviour moduledoc, in anerrors.ex. That is:not_found,{:unavailable,_},{:denied,_},{:rate_limited,_}and the rest, becauseFountain.Retrykeys retryability off those shapes. - Register it. Add the atom to
known_providers/0, the module to@default_adapters, and acredential_present?/1clause. "On" is exactly "you registered the adapter AND the credential is there". There is no other switch. The provider select in the agent form appears on its own, once a second provider is on. - Config. Add the env vars to
config/runtime.exs. You need<PROVIDER>_API_KEY, and a base URL and image knobs, where blank equals unset. Then add rows todocs/configuration.md,.env.example,.env.compose.exampleanddocker-compose.yml. - Conformance. Add a test with
use Fountain.SandboxConformanceCase, adapter: ...and Req.Test stubs for the provider API. Where a sandbox name has to carry a route, mint the names withname: {Mod, :fun, args}, the way the runner adapter does. This is the gate that catches contract drift. It covers exec-never-raises,write_stdintotality, replay from the start, one terminal frame, andallow: []as more than a no-op. - Image. Write a Dockerfile under
images/<provider>/. It bakes thespriteuser, with passwordless sudo and/home/sprite. It bakes node, npm, bun and git, and the agent CLIs. It also bakes an npm global prefix thatspritecan write to, withnpm config set prefix /home/sprite/.npm-global. Without that, thenpm install -g --silentat provision fails as a silent exit 243. If commands run as a user the provider selects, make sure that user issprite. CompareE2B_USER. - Docs. Add a
docs/integrations/<provider>.mdpage and adocs/nav.ymlentry, modelled one2b.mdanddaytona.md. The nav entry is not optional. Fountain serves only the pages the nav names, so a page you leave out of it reaches no reader. The test suite fails until you add it.
Two traps that both adapters hit
- Spawn must not return before the provider acks the process. The ACP
peer writes
initializewithin milliseconds of the return from spawn. An adapter that returns fromspawnwhile the daemon-side registration is still in flight fails prod turns, and the failure looks like "process exited". E2B lost that race in about 300ms, and PR #693 fixed it. Block on the daemon's start acknowledgment. - A local success proves less than you think. The E2B race passed each local run and both full local cycles, on latency luck. Daytona's websocket looked fine in unit tests. The verification ladder below exists because each rung caught something the rung before it could not.
The verification ladder
Run these in order. Each rung has caught a real defect that the rung before it passed.
mix test, with the conformance suite green against your Req.Test stubs.- A live adapter smoke against a real account. Exercise each behaviour callback. That includes suspend and resume with a file that survives the round trip, an attach replay, and an egress deny and allow.
- A full conversation cycle locally, on the custom image.
start_conversation, a real model turn, an idle park, a status check on the provider side, a wake, a second turn, a terminate. The sandbox is then gone on the provider side. - A prod smoke after the merge and the deploy. Pin an agent to the
provider with
sandbox_provideronPOST /api/agents. Run one conversation through the public API. Poll the turn status, and notturn_count, because a turn that failed still counts. Read the answer from/events, terminate, then confirm that the provider account is clean.
Two things remain after that. The provider's key goes to the Infisical prod
project, which syncs it into fountain-secrets and reloads the deployment on
its own. And the image needs a rebuild, because the agent CLIs in it go stale
in weeks.
The Sandbox images workflow owns the rebuild. Give the new provider a
scripts/sandbox-image/build-<provider>.sh that rebuilds the image and then
smokes it. Add a matrix entry, so that docker build proves the Dockerfile on
the runner first. Then add a job that runs the script with the account key as
a repository secret. That key must belong to the account prod uses. An image
that another account builds is not visible to prod.
Every one of those scripts ships scripts/sandbox-image/smoke.sh into the new
sandbox and reads its verdict. Keep the checks in that one file. It is the
statement of what an image must give the provision pipeline, and it holds each
provider to the same contract.