The batch engine for AI agents.
Funky is your always-on task force for running parallel, long-lived agent workloads at scale.
Define your agents, give them sandboxed environments, and send them work. Funky runs sessions in parallel across stateless workers and records every session as an append-only log in Postgres. A worker can die mid-run (SIGKILL, OOM, a deploy) and a fresh one resumes from the log, keeping long-lived workloads moving without repeating side effects.
Requires Docker, an E2B API key, and a key for at least one model provider: Anthropic, OpenAI, Google, Together AI, or xAI.
git clone https://github.com/funkyhq/funky && cd funky
cp .env.example .env # the auth token, a model-provider key, and the E2B key
docker compose up --buildYou can interact with the Funky service through Web UI or CURL.
The console, at http://localhost:5173. Define an agent and its sandbox, give it a task, and watch its log grow — all in the browser. It proxies the api and attaches the token itself, so no credential ever reaches the page.
The api, at localhost:3000 — bearer-authed, and the same steps with curl:
export TOKEN=<your FUNKY_AUTH_TOKEN>
export H="Authorization: Bearer $TOKEN"
export J="content-type: application/json"
# 1. an agent config: the model, the system prompt, and its sandbox's network, packages, keys
# and setup commands
AID=$(curl -s localhost:3000/v1/agent-configs -H "$H" -H "$J" -d '{
"inference": { "provider": "anthropic", "model": "claude-sonnet-5", "maxTokens": 2048 },
"systemPrompt": "You are an autonomous agent in a fresh Linux sandbox.",
"network": { "type": "unrestricted" },
"packages": {},
"credentials": [],
"setupCommands": []
}' | jq -r .id)
# 2. a task: the work, handed to the agent config. It starts a session running it, and the
# answer's sessionId is where that session's log lives
SID=$(curl -s localhost:3000/v1/tasks -H "$H" -H "$J" -d "{
\"agentConfigId\": \"$AID\",
\"content\": \"Run uname -a in your sandbox and tell me what you see.\"
}" | jq -r .sessionId)
# 3. follow the log: each poll asks only for what is newer than the last seq seen
# (an answer that isn't a list of entries is an error: print it and stop)
Q=""
while sleep 1; do
NEW=$(curl -s -H "$H" "localhost:3000/v1/sessions/$SID/entries$Q")
echo "$NEW" | jq -e 'type == "array"' >/dev/null || { echo "$NEW" >&2; break; }
echo "$NEW" | jq -c '.[]'
LAST=$(echo "$NEW" | jq '.[-1].seq // empty')
[ -n "$LAST" ] && Q="?after=$LAST"
doneAgent configs can be updated with POST /v1/agent-configs/$AID. Send only the fields to
replace; omitted fields are preserved. Responses include a monotonic version: include the
current value in an update for optimistic concurrency (a stale value returns 409), or omit it
for an unconditional last-write-wins update. A task pins the version current when it was
created — behavior and sandbox together — so an update reaches only tasks created after it.
packages are installed into the sandbox before the agent's first tool call; see
Packages. credentials let the agent use API keys it can't read; see
Credentials.
The task answers 201 with its id and the sessionId it started — accepted, not done. A
worker claims the run, provisions an E2B sandbox the moment a tool actually executes (an
inference-only turn never pays for one), and every committed step lands in the session's log
as one entry — seq is its place in the log, and GET /entries?after=<seq> answers
everything after it, in order:
{"seq":0,"type":"message","message":{"role":"user","content":[...]}}
{"seq":1,"type":"message","message":{"role":"assistant","content":[{"type":"toolCall","name":"bash",...}],"stopReason":"tool_use"}}
{"seq":2,"type":"message","message":{"role":"toolResult","toolCallId":"...","content":[...]}}
{"seq":3,"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"It's a Linux system..."}],"stopReason":"end_turn"}}
There is no push: Funky runs deferrable work, so you poll as often as you care to, or check
back hours later. The cursor is just the last seq you hold, so a client that stops and
comes back misses nothing. Ctrl-C the loop when done; docker compose down stops the stack
(-v also wipes the database).
Scale workers with docker compose up -d --scale worker=3 — claiming is the only
scheduler; nothing else changes. A worker runs one step at a time by default;
FUNKY_CONCURRENCY=8 in .env has each hold eight claims at once (one process, one pool,
one drain), so a small container serves many sessions while their steps wait on a model or
a sandbox. Scale down the same way: a removed worker stops claiming, finishes a short step
or hands a long one back, and exits.
A task creates a session on an agent config and gives it its first message in one call, so
a failure never leaves an empty session behind. The session is an ordinary one: it takes
follow-up messages with POST /v1/sessions/$SID/messages, and the log above is its log.
GET /v1/tasks lists a namespace's tasks, newest first; GET /v1/tasks/:id reads one back;
POST /v1/tasks/:id/cancel cancels its session's run. POST /v1/sessions still creates a
bare session for experiments, but the console starts work only as tasks.
A config's inference.provider picks the vendor that serves it. At boot the worker wires
one inference adapter per model-provider key it finds and routes each step to the adapter
the session's config names, so one stack runs sessions on different vendors side by side:
inference.provider |
key |
|---|---|
anthropic |
ANTHROPIC_API_KEY |
openai |
OPENAI_API_KEY |
google |
GOOGLE_GENERATIVE_AI_API_KEY |
togetherai |
TOGETHER_API_KEY |
xai |
XAI_API_KEY |
Inference goes through the Vercel AI SDK behind a vendor-neutral port.
packages maps a package manager to specs in its own syntax:
"packages": { "apt": ["graphviz"], "pip": ["pandas==2.2.0"], "npm": ["express@4.18.0"] }Supported on E2B: apt, pip, npm. Each package set is built once into an E2B template
and shared by every session that uses it, so packages work under any network policy.
- The first session on a new set waits for the build.
- Unpinned specs resolve once, at the first build. Pin versions to control them.
- A spec that fails to install ends the run as
"error", with the reason in the tool result.
setupCommands run after the packages install, in the same build:
"setupCommands": ["echo 'hello world' > ~/hello.txt"]- Each runs in its own shell, in order, as the sandbox's user.
- They run once per build, not per session, so they see no credentials.
- A command that fails ends the run as
"error", naming it. Give downloads their own retries.
Most setups skip this. It's only for API keys your agent's tools use, such as a Stripe or
GitHub key: Funky stores them by name (/v1/secrets) in Infisical
rather than Postgres, and agent configs hand them to sandboxes as
credentials. Without it, those routes answer 404 "no secret store configured".
-
In Infisical Cloud (the free plan is enough), create a project, and under its Access Control → Machine Identities create an identity with the Member role.
-
On the identity's page, copy the Client ID under Universal Auth (not the Identity ID), and add a client secret.
-
Put them in
.envwith the project's ID (Project Settings → Copy Project ID), and rundocker compose up -dagain:INFISICAL_CLIENT_ID=... INFISICAL_CLIENT_SECRET=... INFISICAL_PROJECT_ID=... INFISICAL_ENVIRONMENT=dev
PUT /v1/secrets/stripe-live with {"value": "sk_live_…"} stores a key, GET /v1/secrets lists
the names (never the values), and DELETE /v1/secrets/stripe-live removes one.
A credential lets the agent use a stored key it can't read:
"credentials": [
{
"type": "injected",
"env": "STRIPE_API_KEY",
"secret": "stripe-live",
"domains": ["api.stripe.com"],
"header": "Authorization",
"value": "Bearer {secret}"
}
]Outside the sandbox, E2B's egress proxy sets Authorization: Bearer <the stripe-live key> on
HTTPS requests to api.stripe.com. Inside, STRIPE_API_KEY holds a placeholder, funky-injected.
- Keys are read when a sandbox is created, so a changed key misses sessions that already have one.
- Headers only: a key that belongs in a URL, a body or a signature (AWS) can't be injected.
- Tools that check a key's format, like the Stripe CLI, need a stand-in in that format instead, and tools that keep their key in a file need it registered by a setup command.
- A host on
domainsthat echoes request headers hands the key back to the agent.
Most runtimes keep the agent's loop, memory, and state in one process; when it dies, in-flight work dies with it. Funky decouples them:
- The log is the agent. Every session is an append-only entry log in Postgres. Workers are stateless: each step is claim → step → commit in one transaction, and an interrupted step is never committed.
- Crash-safe by construction, not by drain. SIGKILL is the shutdown story: a dying worker's lease expires and any worker resumes from the unchanged log, so crash-safety is exercised on every unplanned death. Tool executions are at-most-once across crashes: a killed batch settles as interrupted results the model can see and retry — never a silently duplicated side effect. Draining on SIGTERM only makes a planned removal cheap: the worker stops claiming, and a step that cannot finish in time is handed back at once (its lease released) instead of after the lease runs out.
- One rendezvous. The api and the workers never talk to each other; Postgres is the only coordination point. Clients follow a session by polling that same log from a cursor.
- Sandboxes outlive workers. Commands run in a per-session E2B sandbox, bound to the session in the store — any worker reconnects to the same box.
| Path | What it is |
|---|---|
packages/core |
The vocabulary: zod schemas for messages, entries, configs, and tool specs |
packages/agent |
The engine and driver: inference fold, tool execution, the claim → step → commit loop; the ports |
packages/adapters |
The port implementations: Postgres store (drizzle), AI SDK inference, E2B sandboxes, Infisical secrets |
apps/api |
The HTTP surface (Hono): configs, secrets, tasks, sessions, intake, cancel, inspection |
apps/worker |
The runDriver host — the process a container runs; reads credentials' keys when a sandbox is created |
apps/web |
The browser console over the api (React + Vite); compose serves it on :5173 |
pnpm install
pnpm test # unit + PGlite-backed suites; no network, no keys
pnpm typecheck
pnpm format:checkThe store conformance suite also runs against a real Postgres when
STORE_TEST_DATABASE_URL is set (CI provisions one). An opt-in end-to-end suite — real
model, real sandbox, kill -9 recovery, and a two-process api+worker run observed entirely
over HTTP — is gated on E2E_DATABASE_URL (a scratch database), ANTHROPIC_API_KEY, and
E2B_API_KEY:
E2E_DATABASE_URL=postgres://... pnpm -F worker test # with the keys in the environmentEarly-stage. The best contribution right now is feedback on the interfaces — open an issue to discuss the protocol, a missing method, or a backend you'd want to plug in.