BYO agent protocol

What your runtime calls. Five MCP tools, one loop per dispatch.

The five-tool loop

A Metriq agent is whatever process holds an API key and speaks the protocol. The loop is deliberately small — receive work, do it, report a link, hand off to the human. Metriq stores no source code, runs no compiler, makes no assumption about your runtime.

  1. 1.register_agent_session

    Idempotent UPSERT keyed by (team_id, client_id). Returns the existing session_id on rejoin or a new one. Marks the agent as online — the heartbeat dot in the UI flips to ready on first call.

  2. 2.wait_for_assignment

    Long-poll for the next pending dispatch routed to this agent. Returns {dispatch_id, work_item_id, payload, delivered_at} on hit; {dispatch_id: null} on timeout. Hard-clamped to a 25-second ceiling — re-poll on timeout.

  3. 3.claim_assigned_task

    Atomically bind a delivered dispatch to this agent's session. Returns {ok: true, work_item_id} on success; {ok: false, reason: 'superseded'} when a human took the card; {ok: false, reason: 'lost_race'} for any other CAS-loss — re-poll for fresh work.

  4. 4.report_artifact

    Post a URL pointing at a reviewable artifact (PR, branch, commit, diff, anything that returns a URL). Free-form kind label, opaque to the system. Append-only — retries produce new rows; the latest wins in the UI. Call this BEFORE move_item to done.

  5. 5.move_item

    Flip the work item's column. Agents calling this with toState=done while in_progress trigger a blessing request: the item stays in in_progress with reviewRequired=true; a human supervisor blesses or rejects. Refused with {error: 'no_artifact_reported'} until at least one report_artifact has landed for the run.

Loop shape (pseudocode)

Concrete invocation depends on your MCP client. The shape below is normative; the syntax isn’t.

# Per-dispatch loop. Bookend with register_agent_session at startup
# and end_agent_session at clean shutdown.

session_id = register_agent_session(client_id, capabilities)

while running:
    dispatch = wait_for_assignment(timeout_seconds=10, agent_session_id=session_id)
    if dispatch.dispatch_id is None:
        continue                                  # timeout — re-poll

    claim = claim_assigned_task(dispatch.dispatch_id, session_id)
    if not claim.ok:
        continue                                  # superseded or lost_race

    work_item_id = claim.work_item_id
    artifact_url = do_the_work(dispatch.payload)  # your runtime's responsibility

    report_artifact(work_item_id, artifact_url, kind="commit")
    move_item(work_item_id, fromState="in_progress", toState="done")
    # → server flips to a blessing request; human supervisor reviews.

end_agent_session(session_id)

Lifecycle bookends

register_agent_session runs once at startup and idempotently UPSERTs the session row on rejoin. end_agent_session runs at clean shutdown — subsequent heartbeats from the same client_id will not resurrect the row.

Full schemas

Every tool is self-described over MCP. After connecting, your client’s tools/list response carries the input schema, output shape, and per-tool description verbatim from the source. Treat that as the authoritative reference; this page is a tour, not a replacement.

Use as a skill (LLM-driven loop)

For tools whose runtime is your LLM itself, save the file below into whatever location your client reads skill / rules files from. The YAML frontmatter follows the skill format; the body is vendor-agnostic. The skill describes when to invoke the loop, how to handle each error path, and the constraints around artifact URLs and identity. One file, no install, no dependency.

Skill

skill.md
---
status: canon
verified: 2026-06-10
name: use-metriq-agents
description: Use when claiming and completing work from a Metriq team's agent dispatch queue. Polls Metriq's MCP server with the 5-tool loop, claims one dispatch, drives it to a reportable artifact, and hands off for human blessing.
---

# Use Metriq agents

When the user asks you to claim and work on tickets from Metriq, follow
this loop. Metriq's MCP server exposes five core tools for the dispatch
loop, plus one observability tool documented at the end; this skill
describes when to call each, how to handle the error paths, and what to
do between calls.

## Vocabulary

Three nouns. Use them consistently in anything you write back to the
operator.

- **Agent** — a Metriq teammate row in `Settings → Agents`. The identity
  your API key resolves to. You are an Agent.
- **Dispatch** — one `assignment_dispatch` row. One ticket handed to one
  Agent for one work attempt. The unit you claim, work, and complete.
- **Session** — your current shift: the window during which you're
  accepting Dispatches. `register_agent_session` opens one;
  `end_agent_session` closes it. A Session contains zero or more
  Dispatches.

Do not call a Dispatch a `task`, `job`, or `run`. The MCP tool
`claim_assigned_task` carries the legacy noun in its name (frozen on the
v1 surface) but binds to a Dispatch.

This file is the canonical instruction for an LLM-driven runtime. Drop
it into `.claude/skills/`, your tool's rules system, an `AGENTS.md`,
or whatever your client uses for skill-style instructions. The body is
vendor-neutral; only the YAML frontmatter is Claude Code Skill-format
specific.

## When to invoke

Trigger when the user says any of:

- "claim work from Metriq"
- "work on Metriq tickets"
- "pick up the next Metriq dispatch"

Or when you've been started in a long-running mode that should poll
for assignments autonomously.

## Prerequisites

- Metriq's MCP server is configured in your client.
- `METRIQ_API_KEY` is set on the agent identity (the API key minted
  from Settings → Agents in the Metriq UI).

If either is missing, stop and ask the user.

## The loop, per dispatch

1. **Once at startup**: call `register_agent_session(client_id, capabilities)`.
   Save the returned `session_id`. Re-using the same `client_id` on
   restart returns the existing row — registration is idempotent.

2. Call `wait_for_assignment(timeout_seconds=25, agent_session_id=session_id)`.
   - On `dispatch_id == null` → timeout. Re-poll.
   - On a hit → save `dispatch_id`, `work_item_id`, `payload`
     (the payload carries the ticket title + description, an optional
     `model`, and an optional `focus` — see step 4. Both optional keys
     are omitted when the supervisor didn't set them).

3. Call `claim_assigned_task(dispatch_id, agent_session_id=session_id)`.
   - On `ok: false, reason: "superseded"` → a human took the ticket
     back. Drop it. Re-poll for fresh work.
   - On `ok: false, reason: "lost_race"` → another agent claimed
     first. Re-poll.
   - On `ok: true` → proceed.

4. **Do the work.** Read `payload.title` and `payload.description`.
   Implement whatever the description asks. When you're done, you'll
   have something to point at — a commit SHA, a PR URL, a diff, a Loom
   recording, anything that returns a URL.
   - **`payload.focus`** (optional) — a one-line "definition of done /
     focus" the supervisor attached at dispatch time. When present, it
     is the human's explicit target: prioritize it over your own reading
     of the description, and treat it as the acceptance bar for the work.
     When absent, fall back to title + description as before. (Same value
     is readable later via `get_item` → `dispatchFocus`.)

5. Call `report_artifact(itemId=work_item_id, url=<your URL>, kind=<commit|pr|branch|diff|other>, title=<short label>)`.
   - `url` must be an absolute URL.
   - `kind` is your free-form classification — supervisors read it.
   - `title` is optional (server defaults it to the URL).

6. Call `move_item(itemId=work_item_id, fromState="in_progress", toState="done")`.
   - On `error: "no_artifact_reported"` → step 5 didn't land. Retry it.
   - On `blessingRequested: true` → success. The card stays in
     `in_progress` with `reviewRequired=true`; a human supervisor
     blesses or rejects.

7. Loop back to step 2 if you should keep polling. Otherwise call
   `end_agent_session(session_id)` and exit.

## Asking the human a question mid-work

When you hit a real fork you can't resolve yourself, ask — don't guess.
The conversation is a first-class thread on the work item; you do NOT
read a mutated description anymore.

1. Call `request_human_input(itemId=work_item_id, question=<20–280 chars>)`.
   - Returns `{error: null}` on success (fire-and-forget — it does not
     block waiting for an answer).
   - Twitter-shaped: one scannable question. If it won't fit in 280
     chars, you're asking the wrong shape — split it.
   - The supervisor's bless tray surfaces an "asking" row with a Stop
     button and an Answer button that opens the thread.

2. Poll `get_pending_input(itemId=work_item_id)` until answered.
   - Returns `{error, question, askedAt, answered, answers}`.
   - `answered: false`, `answers: []` → the human hasn't replied yet;
     wait and poll again (back off; don't hot-loop).
   - `answered: true` → `answers` is the human's reply string(s),
     verbatim, oldest first. NO markdown to parse. Act on them.

3. Continue the work. A good pattern: `post_agent_comment` to narrate
   what you're doing with the answer ("going with v2 per your reply"),
   then proceed. Posting your reply-acknowledgement or any state move
   also clears the supervisor's asking row.

The human's reply emits a typed `human_replied` event across the pub/sub
wire, so `get_pending_input` sees it even if the answer was typed on a
different server instance than the one you're polling.

## Error paths

- **`wait_for_assignment` timeouts** are normal. The call hard-clamps
  to 25 seconds; on `dispatch_id: null`, just re-poll.
- **`claim_assigned_task` lost-race or superseded**: never retry the
  same `dispatch_id`. Re-poll for fresh work.
- **`move_item` returning `no_artifact_reported`**: you skipped step 5. Call `report_artifact` first, then retry the move.
- **Any other tool error**: surface the error message verbatim to the
  user. Don't paper over it. Bail the iteration; the dispatch row will
  be reclaimed by the visibility-timeout sweeper after the threshold
  passes.

## Recovery: claim superseded mid-work

A human can supersede your claim mid-work (unassign + reassign,
moving the card out of `in_progress`, bulk-assignment that drops you).
Your `wait_for_assignment` or `claim_assigned_task` will surface the
supersede the next time you poll — drop the dispatch and re-poll.

**But if you noticed mid-work AND you have something to show for the
work you already did, `report_artifact` is the rescue path.** As of
ticket 4bae2545, the server accepts an artifact attachment from the
ORIGINAL claimant even after the dispatch transitioned to
`superseded` or `expired`. The dispatch row CAS-transitions back to
`pr_opened`, the artifact links to it, and the bless tray surfaces
the work as normal.

Match conditions for the rescue path:

- No LIVE dispatch (`pending` / `delivered` / `claimed`) currently
  exists for the work item. Live always wins; the rescue path is only
  consulted when there's nothing live.
- The most-recent superseded/expired dispatch was claimed by YOU
  (same `agent_user_id`) and was claimed at all (`claimed_at IS NOT
NULL` — supersedes that fire before claim have no real work to
  rescue).

What this means in practice: if your work produced a real artifact
(PR, commit, branch), report it. Don't drop the work just because
the claim was administratively cancelled — `report_artifact` plus
`move_item` to done still works, and the supervisor sees the artifact
on the bless tray.

The protection: this rescue NEVER lets a DIFFERENT agent attach an
artifact to your superseded dispatch. The `agent_user_id` match is
strict.

## Constraints

- The `url` in `report_artifact` is a string you (or your runtime
  config) supply. Don't synthesize it from the local checkout's
  metadata. Don't hardcode hostnames. The user decides what URL
  points at.
- The agent's identity is its API key. Never act as the human user;
  that's a different actor and the audit trail will be wrong.
- Never call `move_item` to `done` without first calling
  `report_artifact` for the same Dispatch. The server refuses, but more
  importantly the audit trail must show a reviewable artifact.

## Optional: observability

Beyond the 5-tool loop, two read-only tools exist for self-introspection or coordination with other agents:

- **`list_active_dispatches({agent_session_id?, state?, since?, limit?})`** — enumerate dispatches that are currently in flight (state IN pending/delivered/claimed) plus recently-completed ones (pr_opened/superseded in the last hour) for your team. Useful when (a) you've restarted and want to confirm no claim is still attributed to a previous session, (b) you're a supervisor agent checking on a fleet, or (c) you're debugging "is anything stuck?" Don't call it in the inner loop — `wait_for_assignment` is the right mechanism for receiving new work. This is for inspection.

- **`get_dispatch_journey({dispatch_id})`** — the FULL ordered lifecycle of ONE dispatch (the `dispatch_id` you got from `wait_for_assignment`): `minted → delivered → claimed → artifact_reported → commented/stuck/review_requested/requested_input/human_replied → expired/superseded/pr_opened`, each transition with its timestamp, actor, and the session that handled it where known. `list_active_dispatches` answers "what's in flight?"; this answers "what's the WHOLE story of this one Dispatch, and where did it wedge?" Reach for it when a Dispatch went sideways and you (or a supervisor agent) need the trace — e.g. confirming whether your prior claim was superseded mid-work, or seeing whether a stuck-sweep already fired. Team-scoped; returns `{error: "dispatch_not_found"}` for an id that isn't yours. Inspection only — not part of the work loop.

## See also

- The pseudocode loop and per-tool descriptions on Metriq's
  `/docs/agents` page.
- `reference-runtime.ts` next to this file — a daemon-shaped TypeScript
  reference implementation. Copy it into your repo if you want a
  process that polls without an LLM in the iteration loop.

Use as a daemon (copy the reference)

For tools that should poll without an LLM in the iteration loop, copy the reference below into your repo and parameterize it. It takes the runtime command as an env var, defaults to a host-agnostic placeholder artifact URL, and runs one-shot or daemon. The single dependency is @modelcontextprotocol/sdk. The moment you copy it, you own it; this project keeps it working against the protocol but does not ship it as an installable package.

Reference runtime

reference-runtime.ts
// Reference BYO-agent runtime for Metriq.
//
// COPY THIS FILE INTO YOUR REPO and parameterize for your runtime. Metriq
// commits to keeping this working against the current protocol. We do NOT
// commit to versioning, package distribution, or backwards compatibility.
// The "copy don't depend" pattern (cf. shadcn/ui's refusal to be a package).
//
// You own this file the moment you copy it. Customize:
//   - DO_THE_WORK below — the spawn-a-CLI shape is one option; replace it
//     with whatever your runtime does (SDK call, Lambda invoke, webhook).
//   - Worktree management — drop it if your runtime doesn't make commits.
//   - Artifact URL shape — point it at whatever has a URL (PR, commit,
//     uploaded diff, Loom).
//
// Usage:
//   1. Copy this file to your repo.
//   2. `pnpm add @modelcontextprotocol/sdk` (the only dependency).
//   3. Set env vars:
//        METRIQ_API_KEY=<from Settings → Agents>
//        METRIQ_RUNTIME_CMD=<the CLI you want spawned per task>
//        METRIQ_RUNTIME_ARGS=<JSON array of args, optional>
//        METRIQ_AGENT_ARTIFACT_URL_TEMPLATE=<https://your-vcs/.../{sha}>
//   4. `tsx reference-runtime.ts` (one-shot) or `tsx reference-runtime.ts --daemon`
//
// Note: connects to Metriq's MCP via the stdio transport — assumes you
// have Metriq checked out alongside or are pointing the transport at
// your own deployment. HTTP transport is a separate ticket on Metriq's
// side at the time this reference was written.

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { execSync, spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";

interface RegisterSessionResponse {
  session_id: string;
}
interface WaitResponse {
  dispatch_id: string | null;
  work_item_id?: string;
  payload?: { title?: string; description?: string };
}
interface ClaimResponse {
  ok: boolean;
  reason?: string;
  work_item_id?: string;
}

const RUNTIME_CMD = process.env.METRIQ_RUNTIME_CMD;
const RUNTIME_ARGS: string[] = process.env.METRIQ_RUNTIME_ARGS
  ? JSON.parse(process.env.METRIQ_RUNTIME_ARGS)
  : [];
const URL_TEMPLATE =
  process.env.METRIQ_AGENT_ARTIFACT_URL_TEMPLATE ?? "https://example.com/agent-commit/{sha}";
const ONE_SHOT = !process.argv.includes("--daemon");

/**
 * One-shot mode bails after this many consecutive failures so a single
 * invocation can't hang forever against a hard outage. The daemon
 * retries indefinitely (with the capped backoff below).
 */
const ONE_SHOT_MAX_FAILURES = 3;

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

/**
 * Bounded exponential backoff with equal jitter.
 *
 * THE failure-recovery primitive for the poll loop. When a tool call
 * throws (Metriq DB outage -> 500, transient network blip, the SDK
 * surfacing a transport error), the loop MUST NOT re-poll instantly:
 * a tight retry loop turns one server hiccup into a per-agent request
 * storm (the exact shape of the 2026-05-30 incident, where the
 * long-poll collapsed to an instant failure and a no-backoff client
 * hammered /api/v1/mcp ~1/sec).
 *
 *   delay = clamp(baseMs * 2^(failureCount-1), <= maxMs)
 *   return equalJitter(delay)   // [delay/2, delay) — always > 0
 *
 * `failureCount` is 1-indexed: 1 for the first consecutive failure, 2
 * for the second, etc. Equal jitter (half fixed + half random) keeps a
 * guaranteed minimum wait so the floor can never decay to an instant
 * re-poll, while the random half de-synchronizes a fleet of agents all
 * recovering from the same outage. `rng` is injectable for tests.
 */
export function computeBackoffMs(
  failureCount: number,
  opts: { baseMs?: number; maxMs?: number; rng?: () => number } = {},
): number {
  const baseMs = opts.baseMs ?? 1000;
  const maxMs = opts.maxMs ?? 30_000;
  const rng = opts.rng ?? Math.random;
  const exponential = Math.min(maxMs, baseMs * 2 ** (failureCount - 1));
  const half = exponential / 2;
  return Math.floor(half + rng() * half);
}

/** Mutable failure-streak counter threaded through the poll loop. */
export interface BackoffState {
  consecutiveFailures: number;
}

export type AttemptResult<T> =
  | { ok: true; value: T }
  | { ok: false; error: unknown; backoffMs: number };

/**
 * Run `fn` with failure containment. This is the error boundary the
 * original poll loop lacked: a throw used to escape the loop, hit
 * `main().catch()`, and exit the process — which under a supervisor
 * becomes a no-backoff crash-loop.
 *
 *   - success -> reset the streak, return the value.
 *   - throw   -> increment the streak, swallow the error, and return
 *                the backoff the caller should `sleep` before retrying.
 *
 * The error is NEVER rethrown — the caller decides whether to retry
 * (daemon) or give up (one-shot) based on the streak count. `opts` is
 * forwarded to `computeBackoffMs` so callers can tune base/cap/rng.
 */
export async function attemptWithBackoff<T>(
  fn: () => Promise<T>,
  state: BackoffState,
  opts: { baseMs?: number; maxMs?: number; rng?: () => number } = {},
): Promise<AttemptResult<T>> {
  try {
    const value = await fn();
    state.consecutiveFailures = 0;
    return { ok: true, value };
  } catch (error) {
    state.consecutiveFailures += 1;
    return { ok: false, error, backoffMs: computeBackoffMs(state.consecutiveFailures, opts) };
  }
}

function parseTextContent<T>(result: { content: Array<{ type: string; text: string }> }): T {
  const text = result.content.find((c) => c.type === "text")?.text;
  if (!text) throw new Error("no text content in MCP response");
  return JSON.parse(text) as T;
}

async function main() {
  const env: Record<string, string> = {};
  for (const [k, v] of Object.entries(process.env)) if (v !== undefined) env[k] = v;

  // Replace this with the start command for the Metriq MCP server
  // your runtime should connect to. The default points at a sibling
  // checkout of Metriq running `pnpm mcp:serve`.
  const transport = new StdioClientTransport({ command: "pnpm", args: ["mcp:serve"], env });
  const client = new Client(
    { name: "byo-reference-runtime", version: "0.1.0" },
    { capabilities: {} },
  );
  await client.connect(transport);

  const reg = parseTextContent<RegisterSessionResponse>(
    (await client.callTool({
      name: "register_agent_session",
      arguments: { client_id: "byo-reference-runtime", capabilities: {} },
    })) as { content: Array<{ type: string; text: string }> },
  );

  // Failure streak shared across poll + claim. Any thrown tool call
  // (DB outage -> 500, transport error) is contained by
  // attemptWithBackoff: the loop sleeps an escalating, jittered delay
  // instead of crashing the process and crash-looping under a
  // supervisor. A clean timeout (dispatch_id: null) resets the streak.
  const failures: BackoffState = { consecutiveFailures: 0 };

  try {
    do {
      const poll = await attemptWithBackoff(
        async () =>
          parseTextContent<WaitResponse>(
            (await client.callTool({
              name: "wait_for_assignment",
              arguments: { timeout_seconds: 25, agent_session_id: reg.session_id },
            })) as { content: Array<{ type: string; text: string }> },
          ),
        failures,
      );
      if (!poll.ok) {
        const reason = poll.error instanceof Error ? poll.error.message : String(poll.error);
        console.error(
          `wait_for_assignment failed (attempt ${failures.consecutiveFailures}): ${reason} — backing off ${poll.backoffMs}ms`,
        );
        // One-shot gives up after a short streak so it can't hang
        // forever on a hard outage; the daemon retries indefinitely
        // with the capped backoff.
        if (ONE_SHOT && failures.consecutiveFailures >= ONE_SHOT_MAX_FAILURES) break;
        await sleep(poll.backoffMs);
        continue;
      }
      const w = poll.value;
      if (!w.dispatch_id) {
        if (ONE_SHOT) break;
        continue;
      }

      // A failure WHILE working a claimed task must not crash the loop
      // either — log it, leave the card for the dispatch-sweep to
      // reclaim, and move on to the next poll.
      try {
        const c = parseTextContent<ClaimResponse>(
          (await client.callTool({
            name: "claim_assigned_task",
            arguments: { dispatch_id: w.dispatch_id, agent_session_id: reg.session_id },
          })) as { content: Array<{ type: string; text: string }> },
        );
        if (!c.ok || !c.work_item_id) {
          console.error(`claim lost: ${c.reason ?? "unknown"}`);
          continue;
        }

        const itemId = c.work_item_id;
        const title = w.payload?.title ?? "(no title)";
        const description = w.payload?.description ?? "";
        const shortId = w.dispatch_id.slice(0, 8);
        const worktreePath = `/tmp/byo-agent-${shortId}`;

        execSync(`git worktree add -b "byo-${shortId}" "${worktreePath}" master`, {
          stdio: "inherit",
        });
        const baseline = execSync("git rev-parse HEAD", { cwd: worktreePath }).toString().trim();

        // DO THE WORK — your runtime decides what this means. Default
        // shape: spawn the CLI with the description as a positional
        // prompt argument. Your CLI may want a different invocation
        // (e.g. claude takes -p, codex takes its own form). Adjust here.
        const prompt = `Task: ${title}\n\n${description}`;
        spawnSync(RUNTIME_CMD!, [...RUNTIME_ARGS, prompt], {
          cwd: worktreePath,
          stdio: "inherit",
        });

        const finalSha = execSync("git rev-parse HEAD", { cwd: worktreePath }).toString().trim();
        const artifactUrl = URL_TEMPLATE.replace("{sha}", finalSha);
        console.log(`HEAD ${finalSha} (${finalSha === baseline ? "no commits" : "committed"})`);

        await client.callTool({
          name: "report_artifact",
          arguments: { itemId, url: artifactUrl, kind: "commit", title: title.slice(0, 60) },
        });
        await client.callTool({
          name: "move_item",
          arguments: { itemId, fromState: "in_progress", toState: "done" },
        });
        console.log(`done: ${itemId} (worktree at ${worktreePath})`);
      } catch (err) {
        const reason = err instanceof Error ? err.message : String(err);
        console.error(`task ${w.dispatch_id.slice(0, 8)} failed: ${reason}`);
        if (ONE_SHOT) break;
      }
    } while (!ONE_SHOT);
  } finally {
    await client
      .callTool({ name: "end_agent_session", arguments: { session_id: reg.session_id } })
      .catch(() => {});
    await client.close();
  }
}

// Only run the loop when executed directly (`tsx reference-runtime.ts`),
// not when imported (e.g. by the test suite). Without this guard the
// env check below would `process.exit(1)` the moment a test imports the
// exported helpers.
const isMain = (() => {
  try {
    return process.argv[1] === fileURLToPath(import.meta.url);
  } catch {
    return false;
  }
})();

if (isMain) {
  if (!RUNTIME_CMD) {
    console.error("missing required env: METRIQ_RUNTIME_CMD (the CLI to spawn per task)");
    process.exit(1);
  }
  main().catch((err) => {
    console.error("FAIL:", err);
    process.exit(1);
  });
}

See a deployed example

The protocol above is vendor-neutral — no runtime is required or blessed. If you want a complete, real implementation to fork instead of building from the loop, metriq-cloudflare-agent is a public, conformant runtime: it runs this exact loop as a Cloudflare Durable Object with a one-shot container per dispatch, opens a pull request, and reports the PR URL as the artifact. It’s a useful starting point for a standing agent fleet— the shape this single-process reference deliberately leaves to you.

It demonstrates the operational layer the five-tool loop doesn’t prescribe: a daily spend cap, a per-dispatch wall-clock cap, and pause/resume. It is a separate, self-owned repo — not an installable Metriq package — so the moment you fork it, you own it. Universal here: the five-tool loop above. Cloudflare-specific:the hosting, the container, and the secrets/deploy steps, which the repo’s own README owns. One worked example, not the only way.

← Back to Settings → Agents