SpedySpedy Docs

Referenz-Loop

Das komplette Pull-Modell in einer Datei -- pollen, claimen, Heartbeat, Befehl ausführen, zurückmelden, freigeben. Kopieren und selbst verantworten.

Agent Loops beschreibt das Protokoll. Diese Seite ist der Code: rund 200 Zeilen, die Spedy nach einem claimbaren Ticket fragen, einen exklusiven Claim nehmen, einen Shell-Befehl ausführen und zurückmelden.

Das ist ein Beispiel, kein Produkt. Es gibt keinen Support. Kein SLA, kein Upgrade-Pfad, keine Garantie, dass es nächsten Monat noch zur API passt. Es führt aus, was in WORK_COMMAND steht, ohne Sandbox — richte es nicht auf ein Repository, das du nicht wiederherstellen kannst.

Die Quelle der Wahrheit ist examples/agent-loop im Spedy-Repository; diese Seite spiegelt sie. Lies ihn, nimm dir, was du brauchst, und schreib den Rest um.

Was der Loop macht

users_me                         # wer bin ich, wie viele Claims darf ich halten

boards_list                      # welche Projekte das Token erreicht (entfällt mit SPEDY_BOARD_ID)

tickets_list { claimable: true } # was ist dort gerade frei

tickets_claim                    # exklusiver, ablaufender Claim

<WORK_COMMAND>                   # dein Agent arbeitet (Heartbeat läuft nebenher)

tickets_report                   # Kommentar + Status + Timer + Freigabe in einem Aufruf

sleep POLL_SECONDS

Spedy ruft nie hinaus. Alles wird von diesem Prozess angestoßen, auf deinem Rechner, mit dem Token deines Agenten.

Einrichtung

  1. Agenten anlegen: Einstellungen → Agenten → Neuer Agent. Agenten-User brauchen Pro und kosten keinen Seat.
  2. Den Agenten als Mitglied der Projekte hinzufügen, in denen er arbeiten soll. Ein Agent kommt wie jeder Mensch über Projekt-Mitgliedschaft an Boards; ein Token allein reicht nicht.
  3. Token erzeugen und einschränken: Nur lesen nur dann abwählen, wenn er wirklich schreiben muss, und erlaubte Projekte setzen, damit ein verunglückter Prompt kein fremdes Projekt anfassen kann. denyDelete ist standardmäßig an — an lassen.
  4. Starten:
export SPEDY_MCP_URL="https://<deine-org>.spedy.ai/api/v1/mcp"
export SPEDY_AGENT_TOKEN="pat_…"       # wird nur einmal angezeigt
export SPEDY_BOARD_ID="…"              # optional: nur ein Projekt
npx tsx loop.ts

Strg-C gibt den Claim vor dem Beenden zurück.

Konfiguration

VariableDefaultBedeutung
SPEDY_MCP_URL— (Pflicht)MCP-Endpunkt deines Tenants
SPEDY_AGENT_TOKEN— (Pflicht)Personal Access Token des Agenten
SPEDY_BOARD_IDalle Projekte, die das Token erreicht (pro Durchlauf über boards_list ermittelt)Auf ein Projekt begrenzen
POLL_SECONDS60Pause zwischen zwei Durchläufen
LEASE_SECONDS1800Claim-Dauer; Heartbeat alle ⅓ davon
WORK_COMMANDclaude -pBefehl, der die Arbeit macht. Prompt kommt über stdin
CLIENT_NAMEspedy-example-loopErscheint als „über …" an allem, was der Loop schreibt

Andere Agenten funktionieren genauso — alles, was einen Prompt über stdin liest und seinen Bericht über stdout schreibt. Prüfe das Flag deines Tools für stdin-Prompts; es unterscheidet sich je nach Tool und Version:

WORK_COMMAND="codex exec -"                          # OpenAI Codex CLI
WORK_COMMAND="cat > /dev/null; echo 'Trockenlauf'"   # harmloser Smoke-Test

Der Code

examples/agent-loop/loop.ts, wortgetreu (Kommentare im Original auf Englisch):

#!/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);
});

Dauerbetrieb

Der Loop ist ein Vordergrundprozess, der zwischen den Durchläufen schläft — gib ihn dem, was deine Dienste ohnehin überwacht. Eine 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

Ein Neustart ist unkritisch: Der Claim eines abgestürzten Loops läuft von selbst ab, danach ist das Ticket wieder claimbar. Genau dafür gibt es den Claim. Wird ein Claim abgeräumt, bekommen die verantwortliche Person am Ticket und der Besitzer des Agenten die Benachrichtigung TICKET_CLAIM_EXPIRED — ein Loop, der ständig stirbt, fällt auf, ohne dass jemand zuschaut.

Statt zu pollen

Pollen ist die einfache Variante, nicht die gute. Weise das Ticket dem Agenten-User zu und abonniere einen Webhook auf ticket.assigned (Einstellungen → Webhooks) — dann weiß deine Seite schon, dass Arbeit da ist, und startet genau einen Durchlauf, statt jede Minute aufzuwachen. ticket.claimed, ticket.claim_expired und ticket.reported ergänzen das Bild, wenn mehrere Loops sich ein Projekt teilen.

Ein Mittelweg ohne Webhooks: Frage tickets_list { claimable: true, assignedToMe: true } ab statt der offenen Warteschlange — dann nimmt der Loop nur Arbeit, die ihm ein Mensch bewusst übergeben hat.

Was dieses Beispiel bewusst NICHT tut

  • Kein Statuswechsel. Es meldet needs_review zurück, das Ticket bleibt claimbar, und der nächste Durchlauf greift dasselbe Ticket erneut. Echte Loops übergeben tickets_report einen statusKey oder filtern auf einen Status, aus dem der Loop die Arbeit herausbewegt. Absichtlich offen gelassen: Welcher Status „fertig" heißt, ist eure Policy — und die Agenten-Policy der Organisation behält DONE womöglich ohnehin einem Menschen vor. Fehlt für einen Statuswechsel die Capability tickets:approve, bleibt der Kommentar stehen und der Status-Schritt landet in issues[] mit PERMISSION_DENIED.
  • Keine Sandbox. WORK_COMMAND läuft mit deiner Shell, deinen Zugangsdaten, deinem Dateisystem.
  • Keine Retries, kein Backoff, keine Parallelität. Ein Ticket nach dem anderen; ein Netzwerkhänger beendet den Durchlauf.
  • Kein Umgang mit Repositories. Kein Clone, kein Branch, kein Commit, kein Push — dafür ist der konfigurierte Befehl da.
  • Keine Secret-Hygiene. Das Token kommt aus der Umgebung und wird aus der Ausgabe des Befehls nicht herausgefiltert.

MCP-Client-Konfiguration

Wenn du dieselben Tools lieber aus einem interaktiven Client statt aus einem Loop fahren willst — der Endpunkt ist derselbe:

{
  "mcpServers": {
    "spedy": {
      "type": "http",
      "url": "https://<deine-org>.spedy.ai/api/v1/mcp",
      "headers": { "Authorization": "Bearer pat_…" }
    }
  }
}

Verwandt

  • Agent Loops — das Protokoll, die Richtlinien, die menschliche Seite
  • Agenten — Agent anlegen, Tokens einschränken, Leitplanken
  • Ticket-Claims-API — derselbe Ablauf über schlichtes HTTP