Skip to main content
In this chapter Linkly gets a link-safety-agent worker: an autonomous worker that samples newly created links and investigates their targets, using iii-sandbox for command execution. On its own, it decides whether to quarantine the link, ask a human operator to confirm deletion, or let it through. The agent uses a real LLM tool-calling loop, with every model call routed through harness so we get end to end traces of the agent’s activity. Whatever the agent decides to run to investigate a link runs inside a sandbox, never on the worker process.

Install the harness

iii worker add harness
The harness, like most workers, is a reusable SDK packaged as a worker, and forms iii’s agent backbone. It is a Node bundle that ships providers for Anthropic, OpenAI, Kimi, LM Studio, plus turn orchestration, credentials, budgets, and approval-gate. Because it runs as a worker, the agent inherits the same iii guarantees as everything else: end to end observability, process isolation, interoperability with the rest of the system, and dynamic registration and execution of new functionality.
Going through the harness worker instead of importing @anthropic-ai/sdk or another harness directly gives one big thing: every LLM call shows up as an iii-observability span.

The agent’s trace becomes link.created → safety::on_link_created → provider::anthropic::complete → sandbox::exec → link::delete and is visible as one unified tree in the console or your OTel provider of choice. A locally-imported vendor SDK would make those calls opaque.
This is the autonomous agent that will sample and investigate links for unwanted activity. For this example we use a few small pieces of the harness and trust that by now you have a good idea of how to incorporate iii’s advanced features into your own projects. If you ever have any questions join our Discord server. The pieces we’ll use in this chapter are iii’s sandbox worker, the database worker, and the Anthropic provider: provider::anthropic::complete.

Install the sandbox for later investigation

The agent investigates a link by probing its target, and it must do that without trusting the target. iii-sandbox boots an ephemeral microVM for each probe, isolated from the link-safety-agent worker and from the host. Add it:
iii-sandbox requires hardware virtualization. macOS works on Apple Silicon (M-series). Linux works with KVM enabled (/dev/kvm readable). Windows requires WSL2.
iii worker add iii-sandbox
This adds the daemon and its config to config.yaml. The image_allowlist controls which images a caller may boot; the agent only needs node:
config.yaml
workers:
  # ...
  - name: iii-sandbox
    config:
      auto_install: true
      image_allowlist:
        - node
      default_idle_timeout_secs: 300
      max_concurrent_sandboxes: 32
      default_cpus: 1
      default_memory_mb: 512

Add databases for harness and the agent

Two workers want their own storage. Harness’s auth-credentials worker stores provider keys in its own SQLite database, and the safety agent keeps a record of what it quarantined (ie. removed from the primary database) in a database of its own. Add both to the database worker’s config:
config.yaml
workers:
  # ...
  - name: database
    config:
      databases:
        primary:
          pool:
            acquire_timeout_ms: 5000
            idle_timeout_ms: 30000
            max: 10
          url: sqlite:./data/iii.db
        harness:
          url: sqlite:./data/harness.db
        safety:
          url: sqlite:./data/safety.db
The link-safety-agent implemented below is intentionally decoupled from the link worker. This means we don’t need to modify link to handle quarantines with an additional table, function(s), and check(s) in link::resolve. All of these concerns stay with the agent. The agent records the quarantined link in its own safety database, then removes it from the link worker through the link::delete you already wrote in Chapter 7, as well as clearing the cache entry in iii-state. While the agent’s database is used as the system of record for why a link was taken down. You write the agent’s quarantine helper as part of the worker below. Scaffold the worker the same way you scaffolded link in Chapter 1:
iii worker init link-safety-agent --language typescript
We’ll now implement it in three files: tool definitions, a deterministic stub (used in the tutorial and in tests when no API key is available), and the engine registration.

Define the agent’s tools

link-safety-agent/src/agent.ts declares the tools the LLM can call (the shape matches Anthropic’s input_schema) and two helpers the loop uses to read the model’s response, pickToolCall and reasonOf:

Create a deterministic stand-in

Before we connect an actual LLM to our agent we’ll first try everything out with a deterministic stand-in for provider::anthropic::complete (link-safety-agent/src/stub.ts). It returns the same messages the real provider does, so the agent loop is identical in both modes. This is only to let us test without using an API key. Later in this chapter you’ll have the option of using a real API key:
link-safety-agent/src/index.ts defines the worker. Start with the imports, the worker handle, and a complete() helper that returns either the deterministic stub or a real provider call. The tool implementations, the investigation loop, and the link.created subscription come next, in their own steps. provider::anthropic::complete is harness’s synchronous endpoint: it drains a streamed turn and hands back the final assistant message, which keeps this loop simple. (harness also exposes provider::anthropic::stream and a turn-orchestrator worker that runs the whole loop for you; a production agent would build on those.)
src/index.ts
import { registerWorker, Logger } from "iii-sdk";
import { TOOLS, SYSTEM_PROMPT, pickToolCall, reasonOf } from "./agent.js";
import { stubDecide } from "./stub.js";

// harness normalizes provider messages into these shapes (a content array of
// typed blocks), not the raw Anthropic wire format.
type ContentBlock = { type: string; [key: string]: unknown };
type Message =
  | { role: "user" | "assistant"; content: ContentBlock[] }
  | {
      role: "function_result";
      function_call_id: string;
      function_id: string;
      content: ContentBlock[];
      is_error: boolean;
    };

const worker = registerWorker(process.env.III_URL ?? "ws://localhost:49134", {
  workerName: "link-safety-agent",
});
const logger = new Logger();

const SAMPLE_RATE = Number(process.env.SAFETY_SAMPLE_RATE ?? "1");
// Default to the deterministic stub so the tutorial runs without ANTHROPIC_API_KEY.
// Set SAFETY_AGENT_STUB=0 to call provider::anthropic::complete for real.
const STUB = process.env.SAFETY_AGENT_STUB !== "0";
const MODEL = process.env.SAFETY_AGENT_MODEL ?? "claude-haiku-4-5-20251001";

async function complete(
  messages: Message[],
): Promise<{ content: ContentBlock[]; stop_reason: string }> {
  if (STUB) return stubDecide(messages);
  return worker.trigger({
    function_id: "provider::anthropic::complete",
    payload: { model: MODEL, system_prompt: SYSTEM_PROMPT, messages, tools: TOOLS },
    timeoutMs: 60_000,
  });
}
The tool implementations are short. inspect_url boots a iii-sandbox and runs curl:
src/index.ts
async function inspectUrl(url: string): Promise<string> {
  const { sandbox_id } = await worker.trigger<{ image: string }, { sandbox_id: string }>({
    function_id: "sandbox::create",
    payload: { image: "node" },
  });
  try {
    const exec = await worker.trigger({
      function_id: "sandbox::exec",
      payload: {
        sandbox_id,
        cmd: "curl",
        args: [
          "-sIL",
          "--max-time",
          "5",
          "-o",
          "/dev/null",
          "-w",
          "%{http_code} %{redirect_url}\\n",
          url,
        ],
        timeout_ms: 8000,
      },
    });
    return exec.success
      ? exec.stdout.trim().slice(0, 400)
      : `inspect failed: ${exec.stderr.slice(0, 200)}`;
  } finally {
    await worker.trigger({ function_id: "sandbox::stop", payload: { sandbox_id } }).catch(() => {});
  }
}
quarantine is where the decoupling pays off. The agent records the link in its own safety database, then removes it from the link worker with link::delete. The link worker never learns what quarantine means. Create the table on startup and write the helper:
src/index.ts
const SAFETY_DB = "safety";

async function ensureSchema(): Promise<void> {
  await worker.trigger({
    function_id: "database::execute",
    payload: {
      db: SAFETY_DB,
      sql: "CREATE TABLE IF NOT EXISTS quarantined_links (code TEXT PRIMARY KEY, url TEXT, reason TEXT NOT NULL, quarantined_at TEXT NOT NULL)",
    },
  });
}

async function quarantine(code: string, url: string | null, reason: string): Promise<void> {
  await worker.trigger({
    function_id: "database::execute",
    payload: {
      db: SAFETY_DB,
      sql: "INSERT INTO quarantined_links (code, url, reason, quarantined_at) VALUES (?, ?, ?, ?) ON CONFLICT(code) DO UPDATE SET reason = excluded.reason, quarantined_at = excluded.quarantined_at",
      params: [code, url, reason, new Date().toISOString()],
    },
  });
  await worker.trigger({ function_id: "link::delete", payload: { code } });
  logger.info("link quarantined", { code, reason });
}

ensureSchema().then(() => logger.info("safety store ready"));
propose_delete reuses Chapter 7’s link::request_delete, which routes through the browser admin and only deletes on confirmation:
src/index.ts
async function proposeDelete(code: string): Promise<{ confirmed: boolean }> {
  return worker.trigger<{ code: string }, { confirmed: boolean }>({
    function_id: "link::request_delete",
    payload: { code },
  });
}
The loop itself reads the assistant message, picks a tool, runs it, appends the result, and calls the model again until it picks a terminal tool:
src/index.ts
async function investigate(link: { code: string; url: string }): Promise<void> {
  const messages: Message[] = [
    {
      role: "user",
      content: [
        {
          type: "text",
          text: `A new shortened link was just created.\n\ncode: ${link.code}\nurl: ${link.url}\n\nInvestigate and decide.`,
        },
      ],
    },
  ];
  for (let turn = 0; turn < 6; turn++) {
    const resp = await complete(messages);
    messages.push({ role: "assistant", content: resp.content });
    const call = pickToolCall(resp.content);
    if (!call) return;
    if (call.name === "allow") {
      logger.info("agent: allow", { code: link.code, reason: reasonOf(call.input) });
      return;
    }
    if (call.name === "quarantine") {
      await quarantine(link.code, link.url, reasonOf(call.input));
      return;
    }
    if (call.name === "propose_delete") {
      await proposeDelete(link.code);
      return;
    }
    if (call.name === "inspect_url") {
      const result = await inspectUrl(
        typeof call.input.url === "string" ? call.input.url : link.url,
      );
      // Feed the result back as a function_result message keyed to the call id.
      messages.push({
        role: "function_result",
        function_call_id: call.id,
        function_id: call.name,
        content: [{ type: "text", text: result }],
        is_error: false,
      });
    }
  }
}
The loop is what makes the agent agentic: the model picks inspect_url (or doesn’t), sees what came back, and decides what to do next. It is not a fixed pipeline. A real run might inspect once and quarantine; another might never inspect because the URL is obviously fine; a third might inspect, see a 302 to a known-bad domain, and propose deletion.

Run investigations on a queue

The agent could subscribe to link.created and run the whole investigation in the subscriber. That works once. The first crash mid-investigation loses the link forever, because pubsub is fire-and-forget. A flood of new links fans out as many concurrent investigations as you have CPU. Both problems go away when the subscriber’s only job is to enqueue an investigation, and the investigation itself is the queue’s consumer. iii-queue then gives you retries on crash, a dead-letter queue for links the agent persistently can’t investigate, and a concurrency cap so investigations never run faster than you can absorb. Add a safety-investigations entry to iii-queue’s queue_configs in config.yaml:
config.yaml
workers:
  # ...
  - name: iii-queue
    config:
      queue_configs:
        safety-investigations:
          type: standard
          max_retries: 3
          concurrency: 2
        clicks:
          type: standard
          max_retries: 5
          concurrency: 5
      adapter:
        name: builtin
In link-safety-agent/src/index.ts, the subscriber enqueues; a separate consumer runs the loop:
src/index.ts
import { registerWorker, Logger, TriggerAction } from "iii-sdk";

// ...

worker.registerFunction("safety::on_link_created", async (data: { code: string; url: string }) => {
  if (Math.random() >= SAMPLE_RATE) return { sampled: false };
  await worker.trigger({
    function_id: "safety::investigate",
    payload: data,
    action: TriggerAction.Enqueue({ queue: "safety-investigations" }),
  });
  return { sampled: true, queued: true };
});

worker.registerTrigger({
  type: "subscribe",
  function_id: "safety::on_link_created",
  config: { topic: "link.created" },
});

worker.registerFunction("safety::investigate", async (data: { code: string; url: string }) => {
  await investigate(data);
  return { investigated: true };
});
When safety::investigate throws, iii-queue retries it (up to max_retries); after that the message lands in the dead-letter queue and the agent moves on. With concurrency: 2, no more than two investigations are ever in flight, no matter how fast link.created events arrive. In production you’d sample at maybe 1% (SAFETY_SAMPLE_RATE=0.01); for this tutorial the default is 1 so every new link is investigated. Register it with your project:
iii worker add ./link-safety-agent

See it work (stub mode, no API key)

With the engine running, create three links:
iii trigger link::create url=https://malware-evil.example code=bad1
iii trigger link::create url=https://phishing-bad.example code=bad2
iii trigger link::create url=https://iii.dev               code=good1
The agent investigates each one. For the malware link, it quarantines: the record lands in the agent’s own safety database, and the link is gone from the link worker.
iii trigger database::query db=safety sql="SELECT code, reason FROM quarantined_links"
# [{"code": "bad1", "reason": "url contains \"malware\""}]

iii trigger link::resolve code=bad1
# {"url": null}            ← the agent deleted it from link; the redirect 404s
For the phishing link, the agent calls link::request_delete, which goes through the browser admin prompt from Chapter 7. With a frontend connected, the operator sees the confirm dialog; otherwise the proposal sits unanswered until someone connects. The benign link is untouched.

Build a remediation tool from traces

A single bad link is often one of many. Someone running an abuse campaign creates dozens of links in seconds, and judging each one in isolation lets the rest through while the agent works. What ties them together is not in any one database row; it is in the trace: a burst of link::create invocations clustered in a few seconds. That is execution context, and the agent reads it from the in-memory trace store you set up in Chapter 2 with engine::traces::list. The remediation does not exist yet. Linkly has no bulk-quarantine. So the agent builds one: it derives the burst’s time window from traces, registers a short-lived safety::purge_window function that quarantines every link created in that window, runs it once, and unregisters it. The capability exists on the bus only as long as it is needed, and each invocation of it is its own span. Add a fifth tool in link-safety-agent/src/agent.ts. Widen the name union and append the tool:
src/agent.ts
name: "inspect_url" | "quarantine" | "propose_delete" | "allow" | "quarantine_burst";
src/agent.ts
{
  name: "quarantine_burst",
  description:
    "Quarantine every link created in the same burst as this one. Use after confirming the link is part of a coordinated batch (many links created within seconds of each other).",
  parameters: {
    type: "object",
    properties: { reason: { type: "string" } },
    required: ["reason"],
  },
},
Extend SYSTEM_PROMPT so the model knows when to reach for it:
src/agent.ts
export const SYSTEM_PROMPT = `You are Linkly's link safety agent. A new shortened link was created. Decide whether to investigate further with inspect_url, then reach one terminal decision: quarantine, propose_delete, or allow. Quarantine is auto-applied; only use it when you are confident. If the link looks like one of many created in a rapid burst, prefer quarantine_burst to take the whole batch down at once.`;
The implementation in link-safety-agent/src/index.ts reads the burst from traces, then builds, runs, and removes the bulk action:
src/index.ts
type Span = { start_time_unix_nano: number; attributes: [string, string][] };
const attrOf = (s: Span, k: string) => s.attributes.find(([key]) => key === k)?.[1];

async function quarantineBurst(reason: string): Promise<{ purged: string[] }> {
  // Execution context comes from traces: every link::create span in the last
  // minute. A cluster of them is the coordinated burst.
  const { spans } = await worker.trigger<{ limit: number }, { spans: Span[] }>({
    function_id: "engine::traces::list",
    payload: { limit: 500 },
  });
  const cutoff = Date.now() * 1e6 - 60e9; // last 60s, in unix nanos
  const created = spans
    .filter((s) => attrOf(s, "function_id") === "link::create")
    .map((s) => s.start_time_unix_nano)
    .filter((t) => t >= cutoff)
    .sort((a, b) => a - b);
  if (created.length < 2) return { purged: [] };
  const start = new Date(created[0] / 1e6).toISOString();
  const end = new Date(created[created.length - 1] / 1e6).toISOString();

  // The bulk action doesn't exist yet. Register it as a real function on the
  // bus so it gets its own span and shows up in engine::functions::list, run
  // it once, then unregister it so it leaves no permanent surface area.
  const fnId = `safety::purge_window_${Date.now()}`;
  const ref = worker.registerFunction(fnId, async (p: { start: string; end: string }) => {
    const { rows } = await worker.trigger<
      { db: string; sql: string; params: string[] },
      { rows: Array<{ code: string; url: string }> }
    >({
      function_id: "database::query",
      payload: {
        db: "primary",
        sql: "SELECT code, url FROM links WHERE created_at BETWEEN ? AND ?",
        params: [p.start, p.end],
      },
    });
    for (const { code, url } of rows) {
      await quarantine(code, url, reason);
    }
    return { codes: rows.map((r) => r.code) };
  });
  try {
    const { codes } = await worker.trigger<{ start: string; end: string }, { codes: string[] }>({
      function_id: fnId,
      payload: { start, end },
    });
    return { purged: codes };
  } finally {
    ref.unregister();
  }
}
Handle the new tool in the loop, alongside the other terminal decisions:
src/index.ts
if (call.name === "quarantine_burst") {
  const { purged } = await quarantineBurst(reasonOf(call.input));
  logger.info("agent: quarantine_burst", { code: link.code, purged });
  return;
}
So the stub can demonstrate it without an API key, have it pick quarantine_burst for campaign URLs. Add this branch to link-safety-agent/src/stub.ts before the allow fallback:
src/stub.ts
if (/burst|campaign/i.test(ctx.url)) {
  return {
    content: [functionCall("quarantine_burst", { reason: "coordinated burst" })],
    stop_reason: "function_call",
  };
}
This version sweeps every link created in the burst’s time span. A production agent would cluster more tightly, requiring sub-second gaps or matching user_agent.original (the one caller signal traces do carry on HTTP-created links) so it never catches an unrelated link created in the same window.

Watch it take down a burst

Create a batch of links pointing at the same campaign, back to back:
for i in 1 2 3 4 5; do
  iii trigger link::create url=https://burst-campaign.example code=burst$i
done
Each create triggers an investigation. The first to reach a verdict reads the trace of recent link::create calls, sees the cluster, builds safety::purge_window_*, and quarantines the whole batch:
iii trigger database::query db=safety \
  sql="SELECT code FROM quarantined_links WHERE reason = 'coordinated burst' ORDER BY code"
# [{"code":"burst1"}, {"code":"burst2"}, {"code":"burst3"}, {"code":"burst4"}, {"code":"burst5"}]
The function the agent built is already gone, unregistered the moment its work finished:
iii trigger engine::functions::list --json '{"include_internal":true}' | grep purge_window || echo gone
# gone

Switch to a real LLM

Set the Anthropic key via auth::set_token:
iii trigger auth::set_token --json '{
  "provider": "anthropic",
  "credential": { "type": "api_key", "value": "sk-ant-…" }
}'
Then turn the stub off by adding an env: block to the worker’s own manifest at link-safety-agent/iii.worker.yaml. iii merges these into the worker process’s environment when it starts the worker, so process.env.SAFETY_AGENT_STUB reads '0':
link-safety-agent/iii.worker.yaml
name: link-safety-agent
runtime:
  kind: typescript
  package_manager: npm
  entry: src/index.ts
scripts:
  install: "npm install"
  start: "npm run dev"
env:
  SAFETY_AGENT_STUB: "0"
Restart the worker so it picks up the change:
iii worker restart link-safety-agent
env: belongs in the worker’s iii.worker.yaml, not under the worker’s entry in the project config.yaml. III_URL and III_ENGINE_URL are filtered out. iii sets those for you from the engine port. Anything else flows through to the process.
Now every link.created triggers a real Claude call through provider::anthropic::complete, which shows up as a span in iii-observability. Open engine::traces::tree on the agent’s trace_id and you’ll see the LLM call, each sandbox::exec it triggered, and the terminal link::delete (from a quarantine) or link::request_delete all in one tree.

Browser-confirmed deletes ride a queue

The agent’s propose_delete tool routes through Chapter 7’s user::confirm_destructive_op in the browser. Currently link::request_delete does the delete server-side after the operator clicks OK. Make the browser the queue producer instead: when the operator confirms, the browser itself enqueues link::delete on a deletes queue. The actual delete becomes durable (retried on failure) and the browser tab can close without losing the work. Add a deletes queue alongside safety-investigations:
config.yaml
workers:
  # ...
  - name: iii-queue
    config:
      queue_configs:
        safety-investigations:
          type: standard
          max_retries: 3
          concurrency: 2
        deletes:
          type: standard
          max_retries: 3
          concurrency: 5
        clicks:
          type: standard
          max_retries: 5
          concurrency: 5
Add link::delete to the browser RBAC allowlist so the browser session is allowed to enqueue it:
config.yaml
workers:
  # ...
  - name: iii-worker-manager
    config:
      port: 3110
      rbac:
        auth_function_id: auth::browser
        expose_functions:
          - match("link::create")
          - match("link::request_delete")
          - match("link::delete")
          - match("stream::*")
In the frontend, update src/App.tsx to enqueue the delete when the operator confirms:
src/App.tsx
import { useEffect, useState } from "react";
import { TriggerAction } from "iii-browser-sdk";
import { worker } from "./iii.js";

// ...inside the user::confirm_destructive_op handler:
worker.registerFunction(
  "user::confirm_destructive_op",
  async (data: { action: string; code: string }) => {
    const confirmed = window.confirm(`Confirm: ${data.action}?`);
    if (confirmed) {
      // Operator approved. Enqueue the delete on the durable `deletes` queue;
      // iii-queue retries on crash, dead-letters on persistent failure, and
      // survives this tab closing.
      await worker.trigger({
        function_id: "link::delete",
        payload: { code: data.code },
        action: TriggerAction.Enqueue({ queue: "deletes" }),
      });
    }
    return { confirmed };
  },
);
The server-side link::request_delete no longer does the delete itself; it relays the operator’s decision back to the agent:
src/index.ts
worker.registerFunction("link::request_delete", async (payload: { code: string }) => {
  const { confirmed } = await worker.trigger<
    { code: string; action: string },
    { confirmed: boolean }
  >({
    function_id: "user::confirm_destructive_op",
    payload: { code: payload.code, action: `delete link "${payload.code}"` },
  });
  return { confirmed };
});

Conclusion

Linkly now has an autonomous link-safety agent. It samples newly created links, decides on its own how to investigate (call inspect_url as many times as it likes), and reaches a terminal verdict: quarantine (auto-applied), propose delete (routed through a human via the browser admin), or allow. When it spots a coordinated burst, it reads the execution context from traces and builds a one-shot safety::purge_window function to take down the whole batch, then unregisters it. Every step (the trigger, each LLM call, each sandbox probe, the bulk action it built, the final verdict) is one span on the trace, because the LLM call goes through harness instead of a private SDK. One housekeeping capability is left. Next, in Ch. 9: Schedule maintenance, you add a link-sweeper worker that expires stale links on a schedule with iii-cron.