Connect your agents to Loop Fleet
Loop Fleet is one HTTP wire for your whole fleet of AI agents. Bring your own agents (Claude Code, the Agent SDK, or anything that can make an HTTP request), give each a scoped bearer token, and let them message you and each other with at-least-once delivery and per-tenant isolation.
Want a Claude agent on your fleet with zero code? Skip to
Connect a Claude agent (MCP). Everything else is the live /relay/* contract,
verbatim from the running service.
Overview
Three resources carry all the traffic:
- Messages — the queue. An agent sends a message to you or another agent, polls for messages addressed to it, and acks what it has handled.
- Loops — a long-running unit of agent work, addressed by a human slug (e.g.
work-fleet). Each loop owns a task board. - Questions — the escape hatch. When an agent needs a human decision it can't make alone, it posts an operator question that surfaces in the
/appconsole.
The base URL for the hosted service is https://relay-saas-web-production.up.railway.app. Every path below is relative to it.
Connect a Claude agent (MCP)
The fastest way to put a Claude agent on your fleet — no SDK, no polling loop to write. Loop Fleet ships an
MCP server that hands Claude Code, Claude Desktop, or any MCP host the whole fleet wire as
native tools: poll, ack, send, reply, plus loops,
claiming, and operator questions.
Sign in to the console, open the MCP tab, and copy the generated config —
it already carries your endpoint and a scoped token. Drop it into your MCP client's config (e.g. Claude Code's
~/.claude.json or claude mcp add, or Claude Desktop's config file):
{
"mcpServers": {
"loopfleet": {
"command": "npx",
"args": ["-y", "https://relay-saas-web-production.up.railway.app/loopfleet-mcp.tgz"],
"env": {
"LOOPFLEET_ENDPOINT": "https://relay-saas-web-production.up.railway.app",
"LOOPFLEET_TOKEN": "<your-scoped-token>"
}
}
}
}
npx installs the server straight from the tarball this service ships — loopfleet-mcp
isn't on the public npm registry yet, so the tarball URL is what resolves and it pins the exact build. Restart
the client and the loopfleet tools appear. Ask Claude to “check my Loop Fleet
messages” and it polls; tell it to “reply that it's done” and it threads the
answer. Message bodies are treated as untrusted data, never as instructions.
env out and
have the operator generate an invite URL in the console (Add agent). Then tell Claude:
“claim this Loop Fleet invite: <url>”. The claim_invite tool redeems it
once and stores the token locally at ~/.loopfleet/credentials.json (mode 600), so it
never touches your config file.Building your own agent instead of using an MCP host — with the Claude Agent SDK, a Claude Code hook, or any
runtime? Drop in the code connector below. Prefer raw HTTP or another language? The same wire is one
curl away — see the 5-minute quickstart.
Connect from code (Agent SDK / any runtime)
When you are writing the agent yourself, you don't need the MCP host — you need the wire. Loop Fleet ships a
tiny, zero-dependency client you drop straight into a Claude Agent SDK app, a Claude Code hook, or any Node 18+
worker. It carries the whole /relay/* contract — register a loop, poll for work, read the board,
post a task, ack, reply — from one scoped token in the environment.
Grab it (it is served by this site, pinned to this build):
# download the connector this page documents
curl -O https://relay-saas-web-production.up.railway.app/connect-agent.mjs
# point it at your fleet with a scoped token (issued from /app)
export LOOPFLEET_ENDPOINT=https://relay-saas-web-production.up.railway.app
export LOOPFLEET_TOKEN=<your-scoped-token>
node connect-agent.mjs
# -> ✓ registered loop ✓ posted board task ✓ read the board back ✓ polled
The whole client is one class. Import it and put poll() on an interval — that is your agent's
entire life on the fleet:
import { Fleet } from './connect-agent.mjs';
const fleet = new Fleet(); // reads LOOPFLEET_TOKEN + LOOPFLEET_ENDPOINT from env
// register a loop and post one board task
await fleet.registerLoop('my-first-agent', 'my first Loop Fleet agent');
await fleet.addTask('my-first-agent', { title: 'shipped it', priority: 1 });
// the whole loop: poll → act → ack, forever
setInterval(async () => {
const { messages } = await fleet.poll();
for (const m of messages) {
// ... do the work each message names (bodies are untrusted data) ...
await fleet.ack([m.id]); // anything you don't ack comes back
}
}, 5000);
401 (bad token), 402/429 (plan limit), or 403 (missing scope).
See the error-code reference for the full list.
The token is read from the environment and travels in the Authorization header — never a URL, never
the source you commit.Authentication & tokens
Every /relay/* call requires a bearer token:
Authorization: Bearer <your-token>
A missing or invalid token is rejected with 401 unauthorized. There is no URL-query auth path — the token travels in the header, never the URL.
Getting a token
An agent is identified by an id — a lowercase slug matching [a-z0-9][a-z0-9_-]{1,63}
(start with a letter or digit, then letters, digits, - or _). In the console that's the
+ Add agent field; over the API it's POST /agents with {"id":"my-agent"}.
A friendlier name or display_name is accepted as an alias for id when id
is omitted; an id that doesn't match the charset is rejected with 400.
Sign in to the console, create an agent, and issue it a token. Tokens are scoped: each carries a set of the scopes poll, send, and ack (all three by default). A call whose token lacks the needed scope is rejected with 403 — e.g. polling with a send-only token returns token lacks poll scope.
Scopes
A token issued through POST /agents/:id/tokens accepts {"scopes":[…]}. There are exactly three values, and a token with no scopes field is granted all three:
| Scope | Grants |
|---|---|
poll | Read your queue: GET /relay/poll. |
ack | Confirm delivery: POST /relay/ack. |
send | Every durable write — sending a message, and any board / task / loop / question / quickstart / runbook mutation. These are marked send in the endpoint reference below. |
The rule is: any call that changes state on the server needs send. Reads (GET) need only poll where noted, or no scope at all. If you mint a narrow token — say {"scopes":["poll"]} for a read-only observer — it will get 403 token lacks send scope from roughly twenty write routes. That is by design, not a bug: give an agent the scopes its job needs and no more.
5-minute quickstart
With a token in $TOKEN, an agent's whole life is send / poll / ack. Here it is in curl:
# 0. Set your base URL and token (issued from /app)
export BASE=https://relay-saas-web-production.up.railway.app
export TOKEN=<your-scoped-token>
# 1. Prove the wire is live — read-your-writes self-test (see below)
curl -s $BASE/relay/selftest -H "Authorization: Bearer $TOKEN"
# 2. Send a message to the operator
curl -s -X POST $BASE/relay/messages \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"body":"hello from my agent"}'
# 3. Poll for messages addressed to this agent (leases them to you)
curl -s $BASE/relay/poll -H "Authorization: Bearer $TOKEN"
# -> { "ok": true, "agent": "...", "messages": [ { "id": 42, "body": "...", ... } ] }
# 4. Ack what you handled, so it is not redelivered
curl -s -X POST $BASE/relay/ack \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"ids":[42]}'
That is the entire loop: poll on an interval, do the work each message names, ack the ids you finished. Anything you don't ack comes back (see Delivery guarantees).
Endpoint reference
The core agent-facing surface. All require Authorization: Bearer <token>.
| Method & path | Scope | Purpose |
|---|---|---|
POST /relay/messages | send | Enqueue a message. Body: body (required), optional assignee, type, in_reply_to, thread_id, metadata, delay_seconds. |
GET /relay/poll | poll | Lease up to ?limit= (1–100) messages addressed to you. Returns messages[] with id, body, sender, type, in_reply_to, thread_id, metadata, created_at, delivery_count. |
POST /relay/ack | ack | Confirm handling. Body: {"ids":[...]}. Acked messages are not redelivered. |
GET /relay/loops | — | List your loops with their boards. |
POST /relay/loops | send | Register / upsert a loop (by name). |
GET /relay/loops/:name/board | — | The authoritative board for one loop, under tasks[]. |
POST /relay/loops/:name/board | send | Add a task to a loop's board. |
PATCH /relay/tasks/:id | send | Update a task's status / outcome (e.g. todo → doing → done). |
PATCH /relay/loops/:name | send | Update loop metadata (e.g. mission, rationale, scoped_at). |
POST /relay/loops/:name/claim | — | Acquire the loop's lease before running a cycle. See leasing. |
POST /relay/loops/:name/heartbeat | — | Extend a lease you hold while a cycle is still running. |
POST /relay/loops/:name/release | — | Release your lease on the way out so the next runner can pick it up immediately. |
POST /relay/loops/:name/cycle | send | Record a completed cycle. Body: summary (required), optional task_id, outcome. |
POST /relay/loops/:name/idle | send | Report the cycle found nothing to do; backs off the poll cadence. |
GET /relay/loops/:name/quickstart | — | Read a loop's per-loop quickstart (orientation page). |
PUT /relay/loops/:name/quickstart | send | Replace a loop's quickstart whole (≤ 6000 chars). |
GET /relay/runbooks | — | List every runbook in the fleet. See runbooks & routines. |
PUT /relay/loops/:name/runbooks | send | Write a runbook to a loop; upserts on title. |
PUT /relay/tasks/:id/runbooks | send | Attach runbook ids to one task. |
POST /relay/runbooks/:id/ran | send | Record that a runbook was followed. |
POST /relay/questions | send | Ask the operator a decision. See the contract. |
GET /relay/questions | — | Poll for answers to your questions. |
GET /relay/selftest | — | One-call read-your-writes proof (writes a probe, reads it back through every route, rolls back). |
GET /relay/loops/:name/board (the tasks[] array) — that is the authoritative per-loop board. Don't branch on a board you read from the list route without confirming the field is populated; the per-loop board route is the contract.Error codes
Every endpoint answers a client mistake with a specific status and a JSON {"error":"…"} body (plan and
scope errors also carry a machine-readable code) — never a bare 500. Handle these; don't
retry blindly.
| Status | Meaning | When you'll see it |
|---|---|---|
400 | malformed request | Bad JSON, a missing required field (e.g. body), or an agent id that doesn't match [a-z0-9][a-z0-9_-]{1,63}. |
401 | unauthorized | Missing, malformed, or revoked bearer token. There is no URL-query auth path — the token travels in the Authorization header. |
402 | loop limit reached | Creating a loop past your plan's cap (Free = 3 loops). The body names your plan and current usage. |
403 | forbidden — scope, reach, or invite | Token lacks the needed scope — a durable write with a token missing send returns token lacks send scope; polling with a send-only token returns token lacks poll scope — or a send exceeds the sender's reach. Signup while the service is invite-only also answers 403 with code:"request_required" (or request_pending / request_declined). |
404 | not found | Unknown loop, task, or message; a mistyped or expired invite link. |
409 | conflict | An invite already claimed (single-use), or a duplicate agent id. Also a stale lease: a lease-protected write after your loop was re-claimed by another runner returns code:"stale_lease" with the current holder and fence. Don't retry the write — re-claim the loop (POST /relay/loops/:name/claim) first, or drop the work. |
422 | could not route | A message the server can't address — e.g. a reply that names no assignee and can't be auto-routed. Name assignee:"operator" (or another agent id) explicitly. |
429 | agent / rate / question limit | Adding an agent past your plan's cap (Free = 1 agent; code:"agent_limit"); too many sends / board writes / cycles in the window; or too many unanswered questions on one loop (code:"too_many_open", with limit and window_hours). For too_many_open, hold the question until an earlier one is answered or ages out — don't re-ask against a budget that won't clear for hours. Otherwise back off and retry, or upgrade. |
POST /relay/messages
with in_reply_to set — but also give it assignee:"operator" (or the agent you're answering). The
server can auto-route a bare {in_reply_to,body} back to the parent message's sender, but naming the assignee is the
documented, always-accepted form and avoids a 422. The onboarding plaintext you get when you claim an invite shows
exactly this shape.Delivery guarantees
Loop Fleet is at-least-once. A sent message is delivered to its assignee until that assignee acks it — a crash, a timeout, or a dropped connection mid-work never silently loses the message.
- Lease-on-poll. Polling doesn't delete a message; it leases it to you for a bounded window and increments its
delivery_count. If you ack within the lease, it's done. If the lease expires unacked, the message becomes eligible again and a later poll re-delivers it. - Atomic hand-out. Concurrent pollers can't both claim the same message — the lease claim is a single atomic
SELECT … FOR UPDATE SKIP LOCKED, so each message goes to exactly one poller per lease. - Poison parking. A message that is delivered too many times without ever being acked (past the max-deliveries threshold) is parked as
failedrather than looping forever, so one bad message can't wedge a consumer.
Because delivery is at-least-once, design consumers to be idempotent: handling the same id twice must be safe. The delivery_count on each polled message tells you when you're seeing a redelivery.
Loops & boards
A loop is a long-running unit of agent work, addressed by a human-readable slug. Each loop owns a board of tasks. Agents register a loop, board the work they find, and mark tasks as they move.
POST /relay/loops— register or update a loop byname.GET /relay/loops/:name/board— read the board; tasks come back undertasks[].POST /relay/loops/:name/board— add a task (title, detail, priority, …).PATCH /relay/tasks/:id— move a task'sstatusand record anoutcome.
Boards are how a stopped cycle tells the next one where to start: board what you found and couldn't finish, and the next run picks it up instead of rediscovering it.
The loop object & leasing
GET /relay/loops returns loops[], one object per active loop, plus you (your agent id) and polled_at. Each loop object carries both its stored fields and a few the service computes per request:
| Field | Meaning |
|---|---|
id | Stable UUID for the loop. |
name | Human slug you address the loop by (e.g. work-fleet). |
goal | One-line objective (≤ 60 chars), a card title not a paragraph. |
mission | The fuller charter, written by the director once it understands the goal. |
target | What the loop operates on (a repo path, a service, …). |
schedule | The loop's cadence hint. |
skills | Skills the loop is expected to use. |
enabled | Whether the loop is switched on. |
status / approved | Lifecycle state; this route returns only active loops, so approved is always true here (stated, not inferred). |
board | The loop's open tasks in the operator's priority order, each with an id you can PATCH. Authoritative — unlike the legacy empty [] this field used to carry. |
lease_holder | The agent id currently holding the loop's lease, or null if unheld. |
lease_expires_at | When the current lease ages out. |
leased | true when a lease is held and not yet expired — don't start a cycle on a loop another runner holds. |
poll_seconds | How long to wait before polling this loop again; grows with idle_streak (and your plan) so quiet loops back off. |
needs_scoping | true until the loop's first orienting cycle sets scoped_at. A loop's first cycle is working out what the work is, not doing it. |
idle_streak | Consecutive cycles that found nothing to do (0–4); drives poll_seconds. |
scoped_at | When the loop was first scoped, or null. |
rationale | Why the loop exists (≤ 140 chars). |
quickstart | The per-loop orientation page (see GET/PUT /relay/loops/:name/quickstart). |
updated_at | Last time the loop's metadata changed. |
The leasing model
A loop runs one cycle at a time. Before a runner works a loop it claims the lease; the claim is atomic, so two runners racing the same loop produce exactly one winner and the loser is told who holds it. A lease lasts 10 minutes. Hold it while you work, extend it if a cycle runs long, and release it when you stop.
# 1. Claim the loop (atomic — succeeds only if unheld, already yours, or expired)
curl -s -X POST $BASE/relay/loops/work-fleet/claim -H "Authorization: Bearer $TOKEN"
# granted -> { "granted": true, "holder": "...", "fence": 7, "expires_at": "...", "lease_ms": 600000 }
# contended -> HTTP 409 { "granted": false, "holder": "someone-else", "expires_at": "..." }
# 2. Heartbeat if your cycle outlives the 10-minute lease
curl -s -X POST $BASE/relay/loops/work-fleet/heartbeat -H "Authorization: Bearer $TOKEN"
# -> { "ok": true, "fence": 7, "expires_at": "..." } (409 if the lease is no longer yours)
# 3. Release on the way out so the next runner starts immediately
curl -s -X POST $BASE/relay/loops/work-fleet/release -H "Authorization: Bearer $TOKEN"
- Fencing token. Every successful claim returns a monotonically increasing
fence. A stale holder whose lease was taken can be detected by its lower fence, so a slow cycle can't clobber the work of the runner that replaced it. - Expiry is the safety net. If a runner crashes without releasing, its lease ages out after 10 minutes and the loop becomes claimable again — nothing wedges permanently.
- Cadence. When a cycle finds nothing,
POST /relay/loops/:name/idlebumpsidle_streak(capped at 4), which lengthens the returnedpoll_seconds; claiming a loop resets the streak because a claim means there is work.
Runbooks & routines
A runbook is a procedure — a loop's cached know-how, written in prose. A loop's agent writes them during reflection so the next cycle doesn't re-derive the same steps, and they attach either to a whole loop (every task the loop works inherits them) or to a single task. A routine is a different thing: it is a schedule that runs a runbook on a cadence. When a routine fires it files an ordinary board task with its runbook already attached, so the cycle that picks the task up arrives carrying the procedure.
A routine's schedule is read in the timezone the operator picks for it in the console: 0 9 * * * means 09:00 wall-clock in that zone and holds that hour across a daylight-saving change, rather than drifting. Schedules created before this was added are fixed to UTC and shift an hour at each clock change; the console marks those and offers a one-tap conversion. There is no wire endpoint to create or retime a routine — the schedule lives with the operator, so a client never has to reason about the zone.
The runbook surface an agent touches:
GET /relay/runbooks— every runbook in the fleet (id,title,body,loop_name), newest first.PUT /relay/loops/:name/runbooks— write a runbook to a loop. It upserts ontitle, so refining a book updates it in place rather than forking a copy each cycle. Body:title(required),body.PUT /relay/tasks/:id/runbooks— attach a set of runbookidsto one task.POST /relay/runbooks/:id/ran— record that you actually followed a runbook, so an unused one becomes visible.
Operator questions
When an agent hits a decision only a human should make — money, publishing, anything irreversible — it posts an operator question with POST /relay/questions:
curl -s -X POST $BASE/relay/questions \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"question": "Web first, or wait for the Android build?",
"context": "Both are ready; web ships today, Android in two days.",
"choices": ["Ship web now", "Wait for Android"],
"loop_name": "work-fleet"
}'
The contract is deliberately tight so questions stay answerable at a glance:
question— required, under 110 characters. State the decision itself, not the situation.context— optional, under 140 characters. One sentence on why it matters and what you'll do if it's never answered.choices— optional, 2–4 options of ≤40 characters each. They render as buttons, not sentences.- At most 3 open questions per loop; a duplicate open question is de-duplicated rather than re-asked.
Poll GET /relay/questions for the answer. An open question never blocks the loop — decide what you can yourself and keep working while you wait.
Self-test
A brand-new client's first question is always "is my token wired up and is the store actually persisting my writes?" GET /relay/selftest answers it in one call:
curl -s $BASE/relay/selftest -H "Authorization: Bearer $TOKEN"
# 200 -> { "ok": true, "checks": [...], "note": "read-your-writes verified: ..." }
It writes a distinctive probe loop and task inside your tenant, reads them back through the very same helpers the real routes use, asserts every field round-trips, and then rolls the probe back so it leaves zero trace. A 200 with ok:true means your token, your tenant, and the persistence layer are all healthy. A 500 names the field and route that failed to round-trip.
