A multi-tenant A2A gateway with a workflow composition layer. Agents register with it, every message between them passes through it, and workflows are definitions the gateway owns rather than code buried inside an agent.
Both ends of every hop speak standard A2A. The gateway is an A2A server facing callers and an A2A client facing targets, so agent authors use off-the-shelf SDKs and there is no proprietary client to adopt. Your agents stay in their own repos, in whatever language you like; the fleet only needs the URL of something that publishes an agent card.
The current implementation supports a subset of A2A and has wire-format compatibility gaps with strict SDKs. See the integration guide before connecting an existing A2A server.
First release (0.1.0). Everything described below works and is covered by tests. See Status for what is deliberately not here yet.
Once you have more than one agent, the interesting problems stop being about any single agent:
- Something has to own the multi-step flow. Put "implement → review → revise → merge" inside an agent and you have coupled two jobs that change at different rates. Here it is a versioned definition, and it is a state machine, not a DAG — the loop back from review to revision is the thing a DAG cannot express.
- Agent work takes minutes to hours. So nothing holds a connection. Every step is a row with a deadline, a callback token and backoff retries.
- A burst of submissions is not a burst of work. Each tenant has a
concurrency limit; submissions beyond it are accepted and held in
queued, then started in order as slots free up. Agents that share state corrupt each other under concurrency rather than merely slowing down. - Discovery has to be scoped. An agent should find the agents it is allowed to find, and nothing else. Tenancy is enforced in routing, not by a permission check bolted on afterwards.
- A human needs to see where a run is stuck. Which is a console, not a log file.
workflow run ──▶ gateway ──message/send──▶ agent
(a definition │ │
the gateway │ (runs for minutes to hours)
owns) ▼ │
next step ◀──── /a2a/callbacks/{token} ◀┘
A step is one fleet_tasks row. The gateway owns delivery, backoff retries and
the deadline sweep; the composition layer sits on top and only answers "what
happens next", so it implements no reliability machinery of its own.
Requires Node 20+ and a Postgres you can reach.
npm install
cp .env.example .env # then fill in DATABASE_URL and the two secrets
npm run migrate
npm run build && npm start # or: npm run devOpen the gateway's root for the console: sign up, connect an agent, write a workflow against the skills it advertises, start a run, and watch it move — a list of runs, how long each has sat in its current state, and the timeline of every step with the gap between them.
Two secrets are not optional. FLEET_SECRET_KEY seals the outbound
credentials the gateway presents to your agents, and BETTER_AUTH_SECRET signs
console sessions — leaking the latter means anyone can forge any user's
session. FLEET_PUBLIC_BASE_URL matters just as much for a different reason:
every callback URL handed to an agent is built from it, so a wrong value means
agents call back to an address that never reaches you.
The login and registration page supports Continue with GitHub. Create a
GitHub OAuth App with your public
origin as its Homepage URL and
<FLEET_PUBLIC_BASE_URL>/api/auth/callback/github as its Authorization callback
URL. Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET in the server environment,
then restart the gateway. Keep the secret out of frontend environment variables.
For local development with npm run dev and npm run dev:web, use
http://localhost:5173 for both FLEET_PUBLIC_BASE_URL and
FLEET_TRUSTED_ORIGINS, and register
http://localhost:5173/api/auth/callback/github as the OAuth callback URL.
Vite proxies the callback to the gateway. Use a separate OAuth App for production.
Successful sign-in opens /app; new users receive their own tenant through
the existing user creation hook. Cancelled or failed authorization returns to
the login page with an error. Email/password sign-in remains available when
GitHub credentials are not configured. See the
Better Auth GitHub guide.
Set the following server environment variables to enable verification:
SMTP_HOST=smtp.gmail.com
SMTP_PORT=465
SMTP_USER=you@gmail.com
SMTP_PASSWORD=your-google-app-password
SMTP_FROM="Fleet <you@gmail.com>"For Gmail, enable two-step verification and create a dedicated
app password. Use the
same Gmail address for SMTP_USER and SMTP_FROM; enter the app password
without the display spaces. Do not commit credentials or expose them through
VITE_* variables. Restart the gateway after setting these values.
Port 465 uses implicit TLS; port 587 requires STARTTLS. Certificate validation
remains enabled.
FLEET_PUBLIC_BASE_URL must be your reachable public origin, and
FLEET_TRUSTED_ORIGINS must include the frontend origin. For local development,
use the Vite origin described above. Verification links expire after one hour.
Registration shows an inbox prompt instead of opening the console. Clicking
the link verifies the address and returns to sign-in. The login page also
offers resending after an unverified sign-in attempt, including for accounts
created before SMTP was enabled. Existing sessions are not revoked.
Leaving all SMTP fields except the port blank preserves sign-in without verification for local development. Partial configuration fails startup. GitHub sign-in continues to use the provider's identity flow.
Resend requests are limited to three per minute per IP by Better Auth's production rate limiter, with a 60-second UI cooldown. Mail is sent in the background; an accepted request does not guarantee inbox delivery. Delivery failures produce a generic server log message without email addresses, tokens, or SMTP credentials. There is no durable mail queue: if the process stops before sending, request another email after restart.
To verify a deployment, register an address you control, confirm sign-in is blocked before verification, open the received link, then sign in. If no message arrives, check spam, SMTP credentials, provider limits and server logs.
The Forgot password? link on the sign-in page sends a reset link using the same SMTP configuration. No additional credentials or database migration are needed. The link expires after 30 minutes and is consumed on successful use. The reset page asks for a new password (8–128 characters) and confirmation, then returns to sign-in. Resetting a password revokes all of that user's existing sessions. Email verification requirements still apply after reset.
The request page gives the same response for registered and unknown addresses. Production requests are limited to three per minute per IP, and the UI imposes a 60-second resend cooldown. Invalid, expired and consumed links offer a path to request a new link. If SMTP is not configured, recovery is unavailable.
npm run build prerenders the actual React landing page into dist/web/index.html.
Its product copy, single H1, links and JSON-LD are readable without JavaScript;
React hydrates the same page to add the interactive scene. dist/web/app.html
is a separate, empty noindex shell for authentication and console routes.
Unknown public paths return 404 rather than duplicating the homepage.
Public metadata lives in web/src/siteMetadata.ts. Update its canonical origin
if the production domain changes. The build also emits robots.txt and a
homepage-only sitemap. The social card is web/public/fleet-social.png
(1200 × 630), with editable artwork in output/brand/fleet-social.svg.
Run npm run check:seo after building to validate the served artifacts.
See the GEO assessment for the scope and limits.
See How to integrate an agent for the complete developer guide, an Agent Card example, message formats, and current SDK compatibility limits. No Fleet-specific SDK is required: use an A2A SDK and describe each skill's input and output contract in its Agent Card declaration.
Running an agent locally without a public URL? Follow the ngrok setup to expose its local HTTP server over HTTPS and connect it to a fleet.
An agent needs to do three things: publish an agent card, accept
message/send, and call back when the work is finished. Anything that does
that can join a fleet.
From the console, paste the agent's URL. The gateway fetches its card through an SSRF policy (private ranges, IPv4-in-IPv6, per-hop revalidation, DNS rebinding), stores the skills it advertises, and issues an inbound token — shown once, because only its hash is kept.
Skills can declare Fleet's optional inputSchema field for machine-readable
validation. The console checks a single structured payload before dispatch,
with one issue per offending field, and the workflow editor provides advisory
checks. Agents must still validate incoming messages themselves.
workflows/develop-review-merge.json is the worked example — implement,
review, revise until the review passes, then ask a human to merge. The loop
back from review to revision is bounded by an iteration limit.
Publish and start a run from the console's editor, which validates as you type, draws the state graph, and warns about anything your actual fleet cannot serve (a skill no connected agent offers, a payload that does not match one). Write the definition as JSON or YAML — the editor converts between them, though a definition is stored as JSON, so YAML comments do not survive a reopen. Or drive it over the machine surface:
curl -X PUT $SITE/a2a/t/$TENANT/workflows/develop-review-merge/1 \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
--data @workflows/develop-review-merge.json
# sourceRef is what makes a retried request idempotent
curl -X POST $SITE/a2a/t/$TENANT/workflows/develop-review-merge/runs \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"payload":{"requirement":"Fix login"},"sourceRef":"issue-9"}'A run stays bound to the definition version it started on. Publish changes with a new version number: publishing the same version again overwrites its stored definition and can affect active runs.
The reference deployment runs on InstaCloud: one
Postgres service and one compute service, wired together by the insta CLI.
The Dockerfile in this repo builds both halves — the gateway and the console
— into a single image.
insta login
insta project create agent-fleet # or run inside an already-linked repo
insta services add postgres agent-db
insta services add compute site
# A provider credential reaches the container only through an explicit
# binding; having a postgres service is not enough.
insta secrets bind DATABASE_URL postgres/agent-db --to compute/site
insta secrets set FLEET_SECRET_KEY "$(openssl rand -hex 32)"
insta secrets set BETTER_AUTH_SECRET "$(openssl rand -hex 32)"
insta build . --port 8790 # local pre-flight, no deploy
insta deploy . --group site --port 8790Leave FLEET_PORT unset there: the container binds the platform's PORT, and
--port has to agree with the EXPOSE line in the Dockerfile.
Then point the gateway at itself. The URL insta deploy prints is what every
callback and agent-facing URL is built from, so it is not optional — an agent
told to call back to the wrong host simply never completes a step:
insta secrets set FLEET_PUBLIC_BASE_URL https://<the-url-deploy-printed>
insta secrets set FLEET_TRUSTED_ORIGINS https://<the-url-deploy-printed>
insta compute restart site # a secret change needs a restartMigrations are files under migrations/, replayed in order. They travel in the
image, so the deployed service can apply them — but run them as a step of their
own, never as a startup gate:
insta compute exec site -- npm run migrate
# or, without involving compute at all:
psql "$(insta db url)" -f migrations/0001_fleet_gateway.sqlBranches are the reason this pairs well. insta branch create <name>
forks the database and clones the compute service, so a new workflow
definition can be exercised against a copy of real data on its own URL,
with its own agents connected, without touching production. Nothing InstaCloud
does merges databases, though — only the files in migrations/ carry schema
forward.
/a2a/* is the machine surface (bearer token). /api/* is the console
(session cookie). Neither accepts the other's credential.
| Endpoint | Purpose |
|---|---|
GET /a2a/t/{tenant}/agents/{id}/.well-known/agent-card.json |
the agent's card, its URL rewritten to the gateway |
POST /a2a/t/{tenant}/agents/{id} |
JSON-RPC: message/send, tasks/get |
POST /a2a/callbacks/{token} |
completion callbacks from agents |
GET /a2a/t/{tenant}/catalog |
same-tenant agent directory |
PUT /a2a/t/{tenant}/workflows/{name}/{version} |
publish a definition (validated on the way in) |
GET /a2a/t/{tenant}/workflows |
published definitions |
POST /a2a/t/{tenant}/workflows/{name}/runs |
start a run |
GET /a2a/t/{tenant}/workflows/runs |
runs, newest first |
GET /a2a/t/{tenant}/workflows/runs/{id} |
one run |
GET /a2a/t/{tenant}/workflows/runs/{id}/events |
a run's timeline |
GET /api/me |
the signed-in user and their tenant |
GET /api/agents |
the tenant's agents, resolved from the session |
POST /api/agents |
register an agent: fetches its card through the SSRF policy, returns a one-time token |
GET /api/agents/{id} |
one agent, with the schema each skill was registered with |
PATCH /api/agents/{id} |
change the endpoint (re-fetches the card) or the outbound credential |
DELETE /api/agents/{id} |
remove an agent; the tasks it ran stay in the ledger |
POST /api/agents/{id}/token |
rotate the inbound token, retiring every earlier one |
POST /api/agents/{id}/messages |
send a message as a person — caller user:{id}, no callback; the payload is checked against the skill's inputSchema before anything is dispatched |
GET /api/tasks/{id} |
poll one task, which is how the console reads a result |
GET /api/workflows |
published definitions |
GET /api/workflows/{name}/{version|latest} |
one definition, for the editor to open |
PUT /api/workflows/{name}/{version} |
publish a version (validated on the way in) |
POST /api/workflows/validate |
check without saving — what the editor calls as you type. Returns issues (the definition against itself, blocking) and warnings (against the tenant's connected agents, advisory) |
POST /api/workflows/{name}/runs |
start a run as a person: created_by is user:{id} |
GET /api/runs |
runs, newest first |
GET /api/runs/{id} |
one run, with the step it is waiting on |
GET /api/runs/{id}/events |
a run's timeline |
GET / and /app/* |
the console (static; unmatched paths fall back to the SPA shell) |
GET /healthz |
liveness |
Splitting the two surfaces is deliberate. One endpoint accepting both a cookie
and a bearer token would give the machine surface a CSRF face it never needed,
and would make caller_agent_id stop being one kind of thing.
Isolation is enforced in the routing layer, not by a permission check: the
gateway resolves a target only inside the caller's own tenant, so another
tenant's same-named agent is simply not in the search set. Postgres RLS is the
backstop underneath — SET LOCAL ROLE switches into an unprivileged role per
request, because the pool connects as the table owner and RLS does not apply to
such a role.
Cross-tenant requests answer 404, not 403: a 403 would confirm that some
other tenant owns that agent id.
npm test # needs a container runtime for the Postgres-backed suites
npm run typechecktest/fleet/workflowEndToEnd.test.ts runs the whole thing over real HTTP
against a real Postgres, with agents on the real A2A runtime. The only stubs
are the agents' business logic — the one part a fleet is not responsible for.
Bug reports, documentation fixes, agent integration examples, and code changes are welcome. For a larger feature or a change to the A2A contract, open an issue first to discuss the intended behavior. For a bug, include reproduction steps, expected and actual results, and relevant logs with credentials removed.
Fork the repository, clone your fork,
and create a branch from the latest main. You need Node.js 20+, PostgreSQL,
and a Docker-compatible container runtime for the database-backed tests.
git switch -c feat/your-change
npm ci
cp .env.example .envIn .env, point DATABASE_URL at a local development database and generate
separate FLEET_SECRET_KEY and BETTER_AUTH_SECRET values with
openssl rand -hex 32. Set both FLEET_PUBLIC_BASE_URL and
FLEET_TRUSTED_ORIGINS to http://localhost:5173, keeping FLEET_PORT=8790.
GitHub OAuth and SMTP are optional for local development; their setup is
described in Quick start.
npm run migrate
npm run devIn a second terminal, run npm run dev:web and open http://localhost:5173.
Vite proxies API requests to the gateway on port 8790. If you are connecting
a local agent over HTTP, set FLEET_ALLOW_INSECURE_AGENTS=1 for local
development only.
The gateway and workflow engine live in src/fleet/, authentication and email
delivery in src/auth.ts and src/email.ts, and the React console in
web/src/. Tests live in test/; the design docs explain the
gateway and workflow model.
Keep each pull request focused, add regression tests for behavior changes, and update documentation when configuration or public APIs change. For code changes, run:
npm run typecheck
npm test
npm run buildThe PostgreSQL-backed tests create disposable databases through Testcontainers,
so start your container runtime before running them. For homepage or search
metadata changes, also run npm run check:seo after the build. For UI changes,
check the affected screens at desktop and mobile widths.
Push your branch to your fork and open a pull request against this repository's
main branch. Describe the problem, what changes for users, and how you tested
it. Link any related issue and include screenshots for visible UI changes.
If a check could not run, say which one and why.
Keep .env files, credentials, and generated build output out of commits.
Contributions are made under the project's Apache 2.0 license.
Working and tested: agent registration and discovery, the task ledger with retries and deadline sweeps, the workflow state machine, the console (sign-up, agents, the workflow editor, run timelines), and payload validation against each skill's declared schema.
Deliberately not in this release:
- Visual drag-and-drop composition. These flows are state machines with loops, and a canvas is bad at exactly that shape. The editor is code plus a derived graph.
- Organisations and invitations. One user is one tenant for now. The membership table is already shaped for multiple members, so that change will not need a data migration.
- Billing. No schema reserved for it.
Known gaps, tracked in the design docs: a run whose very first dispatch fails transiently — the agent was unreachable — is created with no task behind it to retry, so it stays where it is. A step that names a skill nothing offers no longer does that: it ends the run.
Apache 2.0. Copyright 2026 Junwen Feng.
Apache 2.0 rather than MIT for the patent grant: contributors grant a patent licence with their contribution, and that grant terminates for anyone who brings a patent suit over the work. For something meant to sit in the middle of other people's infrastructure, that is worth the extra paragraph.
Developer guide: How to integrate an agent.
These describe the current implementation:
- docs/fleet-a2a-gateway.md — transport, tenancy, the callback chain
- docs/fleet-composition-layer.md — the workflow state machine