← back

spawnlabs

2026

agent platform for domain operators

platform where domain operators, from the best in the world to you, can distill their expertise & skills into agents, tools & automations that autonomously do the work, at their level, and share for anyone; across every domain of knowledge work.

fastapi backend, modal sandboxes, redis streams, nango-held oauth

  • python
  • fastapi
  • postgres
  • asyncpg
  • redis
  • modal
  • claude-agent-sdk
  • mcp
  • nango
  • oauth2
  • websockets
  • nextjs
  • react
  • docker

the problemGeneric agents flatten the thing a firm sells

A professional services firm sells judgment: how this recruiter screens, how this contractor prices a bid. If every firm in a category buys the same AI tool, they all produce the same output and the edge is gone.

The obvious build is a system prompt and a handful of API keys. It falls over in three places, and all three are engineering problems rather than prompting ones. So almost none of this repo is the model call.

generic toolone vendor promptsame output as competitorsno access to their accountsresets every conversationoperator's agenttheir method, written downoutput only they would producetheir oauth, per tenantmemory that outlives the process
what the operator is actually buying
accesslive token per user per providerdurationa bid runs two weeksexecutioncustomer code, real files
three things a system prompt does not solve

the ideaAn agent is a row, a volume, and a mind

The decision everything else follows from: the agent is not the conversation. It is a durable object, and a turn is one wake against it.

Skills are the operator's method as files on the volume, discovered from disk by the Claude Agent SDK rather than stuffed into a prompt. That mattered more than any prompt engineering I did, because a diff of their method is now a diff of a file.

mind
json blob rendered into the prompt at the top of a wake, mutated by a tool at the bottom
skill
a SKILL.md on the volume, resolved from the database first and the filesystem second
the rowprompt, skills, allowed integrationsthe volumemodal, files and artifactsthe mindgoals, state, decisions, questions
what an agent is made of
websocketsomeone typingtriggeran event firedscheduleapscheduleremailits own addressslacka channel it joined
five ways an agent gets woken
browserbackend (gce)modal sandboxoutsidepublishreplayjson on stdinbase_url overridehmac tokenchat + artifactswebsocket handlerauth, then ownershipredis streamreplay cursorcredential proxiesthe only key holderclaude agent sdkmcp tool serverspersistent volumeanthropic apioauth vault
one turn, wake to result
third partyapi (/hooks)postgresmodal sandboxx-hub-signature-256raw byteswebhook_tokenoutcome rowrendered goalnew session idmind block on stdin200, stop retryinggithub, sentry, agentm…/hooks/{source}/{token}triggers/routerhandle_webhooksignature, then seven gat…spawn_triggered_sessiontriggers/runneragent_triggersfound by webhook_tokentrigger_eventsunique row kills retriessessionstrigger_id, is_autonomoussandbox_agent/main.pyreads uploads/event.json
a github webhook wakes an agent

how it runsThe backend is a broker, not a compute host

FastAPI on eight workers, one box, blue-green behind Caddy. It never runs an agent: it authenticates, authorises, writes a JSON line to a sandbox's stdin, and forwards what comes back.

That forces every piece of cross-worker state into Redis, which is the right constraint, because with no sticky sessions a deploy can swap the slot under a live turn. A cold Modal sandbox takes five to eight seconds to boot, so new sessions claim a pre-booted one in about 200ms instead.

webNext.js 15 · React 19 · separate auth app on a shared cookieapiFastAPI · 8 uvicorn workers · blue-green behind CaddystateNeon Postgres via asyncpg · Upstash Redis streams + pub/subPinecone for memoryexecutionModal sandboxes · Claude Agent SDK · persistent volumescredentialsself-hosted Nango · Fernet columns · GCP Secret Manager
what sits on what
1reconnectsends last event id2pipelinexrange + exists done3replay4live tail
a dropped client catches back up
hand-written mcp60 providerstool surface is shaped by me280 endpoints become 5one file per provideruniversal servereverything elsereads nango script schemas at bootbuilds a function per actionsynthesised signature, never exec
two halves of the integration layer
modal sandboxapi workerredisbrowserjson linestdout, 11s chunksspawn-event-streamturn_done ends itxadd_new_donefrom last_event_idws frame_eid_emitndjson on stdoutevent queue drainer15ms batches_owner_event_queue_dra…modal_transportAgentRunner._on_message_forward_subscription_…sends _raw untouchedstream:agent:{id}xadd, maxlen 10000chan:agent:{id}wakes other workersstream:done:{id}terminal sentinelsession/[id]/page.jsxdedupes on _eiduseAgentMessageStreamcontent_block_delta
a token gets from the sandbox to the tab

credentialsNothing secret enters the sandbox

The sandbox runs code an operator wrote and a model extended, so it is the least trusted process in the system. It gets one HMAC session token naming a session, a user and a scope, plus three proxy URLs.

The narrow part I like: a 429 carrying none of Anthropic's rate limit reset headers is probably load balancer throttling, so that case retries the same credential once instead of locking a healthy key out of the pool.

in postgres
a connection id and a status, no token, so a dump of it leaks nothing
known weak spot
fernet columns have no key version, so rotation means a backfill
keys in the sandboxmodel key in envvendor keys in envuser oauth token in envone escape leaks everythingtoken in the sandboxhmac token as the api keybase url points at the proxyno oauth token ever presentan escape leaks one session
the obvious way vs the way it works
sandboxbackendvaultproviderconnection idaccess tokenresultmcp tool callverify hmac tokenre-check ownershipconnection belongs to usernangoholds and refreshesgmail, slack, sheets
a credentialed call, end to end

the hard partsTwo things that were genuinely difficult

Tenancy was not eight bugs. It was one habit with eight faces, and in four of the five worst cases the correct check already existed elsewhere in the repo for the same value. Human review kept passing because each diff looked exactly like the file around it.

The second was money. The Claude Agent SDK reports cost as a running total for the life of a client, not the cost of the turn that ended, and every persist path added that total to the session. Charged cost grew with the square of the turn count.

the fix
one function reads the cumulative figure, swapping old baseline for new in a single statement
cost of the suite
about nine seconds, and it cannot tell you whether a gate is the right gate
the hour-long one
an old second VM kept winning the redis leader election, so a correct sweep ran zero times
1import appevery real route2lift idspath, body, query3taint walkover the handler ast4match gate5fail on new9 open, 13 allowed
the ownership contract test
browserapipostgresciaccess_tokencaller idagent_id off the wiresource_agent_idjoined to callergate must be reached9 leftfirst ws messageprompt, source_agent_idauthenticate_websocketorigin check on cookieresolve_session_accessspawn/helpersauthorize_source_agentthe id nobody gatedsessionsowner, team, shareentity_share_accessgrant rowsagentsprompt, brain, volumetest_route_ownership_c…taint walk, three halves_LiftBodyIdsbody ids become localsOPEN_FINDINGSmay only shrink
two ids on one socket frame
modal sandboxapipostgresrunning totalold baseline out, new inthe deltadelta plus tool spendcreditsburn, agent_runresult messagetotal_cost_usd, cumulative_finalize_turnrouters/spawn/_coreresolve_turn_costone locking statementdeduct_credits_for_turnoutside the transactionsessions.last_sdk_cost_usdbaseline moves forwardmessages.cost_usdthis turn onlyenterprise_credit_balancesused_credits, for updatecredit_transactionsone burn row
what one turn costs, and who pays
billed to the customer$291.29real model spend$19.43
one 33-turn session, before the fix

where it standsWhat works and what is still wrong

It runs. Operators build agents, attach skills and connections, publish them, and other people clone them; a clone copies the volume then prunes every reference folder not marked as a template, so personal material does not ship with the copy.

Production has been on emergency infrastructure since a billing lockout, and the runbook I wrote has gone stale in an expensive direction: two VMs are billing, DNS points at the clone, and the disks have diverged for three months. The way back is a migration, not a DNS flip.

spawnlabs-core
backend, web app, sandbox agent, nango action scripts
spawnlabs-auth
separate Next app, issues the cookie on the parent domain
spawn-admin
cloud run console: credential pool health, oauth credentials, cache invalidation
nango action scripts151 countplatform tools102 countmapped providers61 countmcp servers28 count
the surface an agent can reach
impersonationno actor claim, no audit row9 findingsnamed, not fixedheader idsunmodelled by the testkey rotationneeds a backfillmigration orderstring sort, must be idempotentstale docssays 41 providers, map has 61
open, in order of how much it bothers me
browserapipostgresmodal volumeagent_idstatus publishedrow with spawned_from_idtemplate rows onlybackground taskkept slugsthe operatorneeds name and descriptionsomeone elseadds it to their library/api/agents/{id}/publi…author_id must match/api/agents/{id}/spawnvisibility, then paywallclone_agent_volumeservices/volume_cloneragentsstatus publishedagent_skillscopied with priorityagent_brain_statetemplate_memory if offici…agent_ref_topicslayer = template onlyagent-{new_id}wholesale file copyremove_file on ref/learned folders deleted
publish, then somebody takes a copy