SpedySpedy Docs

Reference Loop

The whole pull model in one file -- poll, claim, heartbeat, run a command, report, release. Copy it and own the result.

Agent Loops describes the protocol. This page is the code: about 200 lines that poll Spedy for a claimable ticket, take an exclusive lease, run a shell command, and report back.

This is an example, not a product. It is unsupported. No SLA, no upgrade path, no guarantee it still matches the API next month. It runs whatever you put in WORK_COMMAND with no sandbox — do not point it at a repository you cannot restore.

The source of truth is examples/agent-loop in the Spedy repository; this page mirrors it. Read it, copy the parts you need, rewrite the rest.

What it does

users_me                         # who am I, and what is my claim ceiling

boards_list                      # which boards this token reaches (skipped if SPEDY_BOARD_ID is set)

tickets_list { claimable: true } # what is free right now, per board

tickets_claim                    # take an exclusive, expiring lease

<WORK_COMMAND>                   # your agent does the work (heartbeat runs alongside)

tickets_report                   # comment + status + timer + release, in one call

sleep POLL_SECONDS

Spedy never calls out. Everything is initiated by this process, on your machine, with your agent's token.

Setup

  1. Create an agent in Spedy: Settings → Agents → Create agent. Agent users are a Pro capability and cost no seat.
  2. Add it as a member of the boards it should work on. An agent reaches boards through board membership like any person; a token alone grants nothing.
  3. Mint a token for it. Scope it: turn read-only off only if it really needs to write, and set allowed boards so a stray prompt cannot touch a project it has no business in. denyDelete is on by default — leave it on.
  4. Run it:
export SPEDY_MCP_URL="https://<your-org>.spedy.ai/api/v1/mcp"
export SPEDY_AGENT_TOKEN="pat_…"       # shown once when you mint it
export SPEDY_BOARD_ID="…"              # optional: one board instead of all of them
npx tsx loop.ts

Ctrl-C releases the claim before exiting.

Configuration

VariableDefaultMeaning
SPEDY_MCP_URL— (required)Your tenant's MCP endpoint
SPEDY_AGENT_TOKEN— (required)The agent's personal access token
SPEDY_BOARD_IDall boards the token reaches (resolved per pass via boards_list)Restrict to one project
POLL_SECONDS60Sleep between passes
LEASE_SECONDS1800Lease length; the loop heartbeats at a third of it
WORK_COMMANDclaude -pThe command that does the work. Prompt on stdin
CLIENT_NAMEspedy-example-loopShown as "via …" on everything the loop writes

Other agents work the same way — anything that reads a prompt from stdin and writes its report to stdout will do. Check your tool's own flag for reading a prompt from stdin; they differ between tools and between versions:

WORK_COMMAND="codex exec -"                        # OpenAI Codex CLI
WORK_COMMAND="cat > /dev/null; echo 'dry run'"     # a harmless smoke test

The code

examples/agent-loop/loop.ts, verbatim:

#!/usr/bin/env -S npx tsx
/**
 * ⚠️  EXAMPLE, NOT A PRODUCT. UNSUPPORTED. ⚠️
 *
 * A minimal pull loop: it asks Spedy for a claimable ticket (on one board, or on
 * every board the token reaches), takes an exclusive lease on it, runs a shell
 * command to do the work, and reports back. Spedy never calls out — everything
 * here is initiated by this process, on your infrastructure, with your agent's
 * token.
 *
 * Read it, copy it, rewrite it. It exists to show the protocol, not to be
 * depended on: no retries worth the name, one ticket at a time, no sandboxing
 * of the command it runs. Do not point it at a repository you cannot restore.
 *
 * Run:  SPEDY_MCP_URL=... SPEDY_AGENT_TOKEN=... npx tsx loop.ts
 * Stop: Ctrl-C (releases the claim before exiting)
 */

import { spawn } from 'node:child_process';

// ── Configuration ────────────────────────────────────────────────────────────

const MCP_URL = required('SPEDY_MCP_URL');
const TOKEN = required('SPEDY_AGENT_TOKEN');
/** Optional: pin the loop to one board. Unset = every board the token reaches. */
const BOARD_ID = process.env.SPEDY_BOARD_ID || undefined;
const POLL_SECONDS = Number(process.env.POLL_SECONDS ?? 60);
const LEASE_SECONDS = Number(process.env.LEASE_SECONDS ?? 1800);
/** The command that does the work. It receives the prompt on stdin. */
const WORK_COMMAND = process.env.WORK_COMMAND ?? 'claude -p';
const CLIENT_NAME = process.env.CLIENT_NAME ?? 'spedy-example-loop';

function required(name: string): string {
  const value = process.env[name];
  if (!value) {
    console.error(`Missing ${name}. See README.md.`);
    process.exit(1);
  }
  return value;
}

// ── MCP over plain JSON-RPC ──────────────────────────────────────────────────
// The whole client. No SDK: an MCP tool call is one HTTP POST, and a tool
// result is JSON inside a text block.

let rpcId = 0;

/**
 * The session Spedy opened for us on `initialize`, handed back in the
 * `Mcp-Session-Id` response header.
 *
 * Echoing it on every later call is not optional bookkeeping: the name this
 * loop announces in `clientInfo` is remembered *per session*, so a client that
 * drops the header gets a fresh anonymous session per request and everything it
 * writes lands without a "via …". Attribution is deliberately session-scoped
 * rather than per-call, so that a caller cannot relabel itself request by
 * request.
 */
let sessionId: string | null = null;

async function rpc(method: string, params?: unknown): Promise<any> {
  const response = await fetch(MCP_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${TOKEN}`,
      ...(sessionId ? { 'Mcp-Session-Id': sessionId } : {}),
    },
    body: JSON.stringify({ jsonrpc: '2.0', id: ++rpcId, method, params }),
  });
  if (!response.ok) throw new Error(`${method} → HTTP ${response.status}`);

  const issued = response.headers.get('mcp-session-id');
  if (issued) sessionId = issued;

  const body = await response.json();
  if (body.error) throw new Error(`${method} → ${body.error.message}`);
  return body.result;
}

/** A tool result: `{ status, summary, data }`. `denied` is an ANSWER, not a throw. */
async function callTool(name: string, args: Record<string, unknown> = {}) {
  const result = await rpc('tools/call', { name, arguments: args });
  const text = result?.content?.[0]?.text;
  if (!text) throw new Error(`${name} returned no content`);
  return JSON.parse(text).result as {
    status: 'ok' | 'denied' | 'error';
    summary: string;
    data?: any;
  };
}

// ── The loop ─────────────────────────────────────────────────────────────────

/** The ticket we currently hold a lease on — SIGINT hands it back. */
let held: { id: string; label: string } | null = null;
let stopping = false;

async function main() {
  // `initialize` is how the loop names itself, and it opens the session that
  // `rpc` echoes from here on. Spedy stores the name against that session and
  // shows "via spedy-example-loop" on every comment, time entry and status move
  // the loop makes — the attribution humans see afterwards.
  await rpc('initialize', {
    protocolVersion: '2024-11-05',
    capabilities: {},
    clientInfo: { name: CLIENT_NAME, version: '0.1.0' },
  });

  const me = await callTool('users_me');
  console.log(`Connected as ${me.data.name}${me.data.isAgent ? ' (agent)' : ''}`);
  if (!me.data.isAgent) {
    console.warn('This token belongs to a person, not an agent. Claims will not be capped.');
  }

  while (!stopping) {
    const ticket = await claimSomething();
    if (ticket) {
      await work(ticket);
    } else {
      console.log(`Nothing claimable. Sleeping ${POLL_SECONDS}s.`);
    }
    if (!stopping) await sleep(POLL_SECONDS * 1000);
  }
}

/**
 * The boards to poll this pass: the one you pinned, or every board the token
 * reaches.
 *
 * `tickets_list` is per board — there is no cross-board claimable feed — so
 * without SPEDY_BOARD_ID the loop has to ask which boards it may see. That is
 * `boards_list`, and it already answers within the token's limits: a PAT scoped
 * with `allowedBoardIds` gets only those back. Re-read every pass rather than
 * once at startup, so granting the agent a new board takes effect without a
 * restart.
 */
async function boardsToPoll(): Promise<string[]> {
  if (BOARD_ID) return [BOARD_ID];

  const list = await callTool('boards_list', { limit: 100 });
  const boards: Array<{ id: string; isArchived?: boolean }> = list.data?.boards ?? [];
  return boards.filter((board) => !board.isArchived).map((board) => board.id);
}

/**
 * Take the first ticket nobody else holds, across every board we poll.
 *
 * `claimable: true` already excludes live leases and finished work, but two
 * loops polling the same board still race — so a lost claim comes back as
 * `denied` and we simply try the next candidate. Never treat that as an error.
 */
async function claimSomething(): Promise<{ id: string; label: string } | null> {
  const boardIds = await boardsToPoll();
  if (boardIds.length === 0) {
    console.log('This token reaches no boards. Add the agent to one (Settings → Agents).');
    return null;
  }

  for (const boardId of boardIds) {
    const list = await callTool('tickets_list', {
      boardId,
      claimable: true,
      limit: 5,
    });

    for (const candidate of list.data?.tickets ?? []) {
      const label = candidate.displayId ?? candidate.id;
      const claim = await callTool('tickets_claim', {
        ticket: candidate.id,
        leaseSeconds: LEASE_SECONDS,
        assign: true,
      });

      if (claim.status === 'ok') {
        held = { id: candidate.id, label };
        console.log(`Claimed ${label}: ${candidate.title}`);
        return held;
      }
      // CLAIM_LIMIT_REACHED means this agent already holds its maximum — no other
      // ticket on any board will help, so stop asking.
      if (claim.data?.code === 'CLAIM_LIMIT_REACHED') {
        console.log(`At the claim limit: ${claim.summary}`);
        return null;
      }
      console.log(`${label} went to someone else: ${claim.summary}`);
    }
  }
  return null;
}

async function work(ticket: { id: string; label: string }) {
  // `tickets_resolve`, not `tickets_get`: the latter wants a boardId the list
  // does not hand back, and the loop has no reason to care which board it is.
  const detail = await callTool('tickets_resolve', { identifier: ticket.id });
  const prompt = buildPrompt(detail.data);

  // Heartbeat while the command runs. The lease expires on its own if this
  // process dies, which is the point: a crashed loop must not block a ticket.
  const heartbeat = setInterval(() => {
    callTool('tickets_heartbeat', { ticket: ticket.id, extendSeconds: LEASE_SECONDS }).then(
      (result) => {
        if (result.status !== 'ok') {
          // The lease is gone — the sweeper cleared it, or a human released it.
          // Whatever we produce from here is no longer ours to report.
          console.warn(`Lost the lease on ${ticket.label}: ${result.summary}`);
          held = null;
        }
      },
      (error) => console.warn(`Heartbeat failed: ${String(error)}`),
    );
  }, Math.max(30_000, (LEASE_SECONDS / 3) * 1000));

  let output = '';
  let failed = false;
  try {
    output = await run(WORK_COMMAND, prompt);
  } catch (error) {
    failed = true;
    output = String(error);
  } finally {
    clearInterval(heartbeat);
  }

  if (!held) {
    console.warn(`Not reporting ${ticket.label} — the lease was no longer ours.`);
    return;
  }

  // One call ends the run: it posts the report comment, links a PR if the
  // output mentions one, moves the status if policy allows, stops the timer
  // and releases the claim.
  const report = await callTool('tickets_report', {
    ticket: ticket.id,
    outcome: failed ? 'blocked' : 'needs_review',
    summary: summarise(output),
  });
  console.log(`Reported ${ticket.label}: ${report.summary}`);
  held = null;
}

function buildPrompt(ticket: any): string {
  return [
    `Work on ticket ${ticket.displayId ?? ticket.id}: ${ticket.title}`,
    '',
    ticket.description ?? '(no description)',
    '',
    'Make the change, run the tests, and describe what you did and what is still open.',
  ].join('\n');
}

/** Last 4000 characters of the command output — the report comment, not a log. */
function summarise(output: string): string {
  const trimmed = output.trim();
  if (!trimmed) return 'The command produced no output.';
  return trimmed.length > 4000 ? `…${trimmed.slice(-4000)}` : trimmed;
}

function run(command: string, stdin: string): Promise<string> {
  return new Promise((resolve, reject) => {
    // `shell: true` so WORK_COMMAND can carry its own flags. That also means it
    // runs whatever you put in the variable — this is your machine, not a sandbox.
    const child = spawn(command, { shell: true, stdio: ['pipe', 'pipe', 'pipe'] });
    let out = '';
    child.stdout.on('data', (chunk) => (out += chunk));
    child.stderr.on('data', (chunk) => (out += chunk));
    child.on('error', reject);
    child.on('close', (code) =>
      code === 0 ? resolve(out) : reject(new Error(`Command exited ${code}:\n${out}`)),
    );
    child.stdin.write(stdin);
    child.stdin.end();
  });
}

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

// Hand the ticket back on the way out. A lease we do not release blocks the
// ticket until it expires — correct, but rude when we know we are leaving.
process.on('SIGINT', () => {
  if (stopping) process.exit(1);
  stopping = true;
  console.log('\nStopping…');

  const release = held
    ? callTool('tickets_release', { ticket: held.id, reason: 'loop stopped' }).catch(() => {})
    : Promise.resolve();
  release.then(() => process.exit(0));
});

main().catch((error) => {
  console.error(error);
  process.exit(1);
});

Running it continuously

The loop is a foreground process that sleeps between passes — hand it to whatever already supervises your services. A systemd unit:

[Unit]
Description=Spedy agent loop
After=network-online.target

[Service]
WorkingDirectory=/opt/spedy-agent-loop
EnvironmentFile=/etc/spedy-agent-loop.env
ExecStart=/usr/bin/npx tsx loop.ts
Restart=always
RestartSec=30

[Install]
WantedBy=multi-user.target

Restarting is safe: the lease of a killed loop expires on its own and the ticket becomes claimable again. That is the point of the lease. When a lease is swept, the ticket's assignee and the agent's owner get a TICKET_CLAIM_EXPIRED notification — a loop that keeps dying is visible without anyone watching.

Not polling

Polling is the simple option, not the good one. Assign a ticket to the agent user and subscribe a webhook to ticket.assigned (Settings → Webhooks) — then your side already knows there is work and can run one pass instead of waking up every minute. ticket.claimed, ticket.claim_expired and ticket.reported round out the picture when several loops share a board.

A middle road without webhooks: poll tickets_list { claimable: true, assignedToMe: true } instead of the open queue, so the loop only ever picks up work a human deliberately handed it.

What this example does NOT do

  • No status change. It reports needs_review, which leaves the ticket claimable, so the next pass picks the same ticket again. Real loops pass a statusKey to tickets_report, or filter on a status the loop moves work out of. Left out on purpose: which status means "done" is your policy, not ours, and the organization's agent policy may reserve DONE for a human anyway. A report that tries to move the status without the tickets:approve capability keeps the comment and puts the status step in issues[] with PERMISSION_DENIED.
  • No sandbox. WORK_COMMAND runs with your shell, your credentials, your filesystem.
  • No retries, no backoff, no concurrency. One ticket at a time; a network blip ends the pass.
  • No repository handling. It never clones, branches, commits or pushes — that is what the command you configure is for.
  • No secret hygiene. The token comes from the environment and is not redacted from anything the command prints.

MCP client config

If you would rather drive the same tools from an interactive client instead of a loop, the endpoint is the same:

{
  "mcpServers": {
    "spedy": {
      "type": "http",
      "url": "https://<your-org>.spedy.ai/api/v1/mcp",
      "headers": { "Authorization": "Bearer pat_…" }
    }
  }
}
  • Agent Loops — the protocol, the policies, the human side
  • Agents — creating an agent, scoping its tokens, its guardrails
  • Ticket Claims API — the same flow over plain HTTP