> ## Documentation Index
> Fetch the complete documentation index at: https://motiadev-add-real-system-tutorial-round-2.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Ch. 8: Create sandboxed link safety agents

> Drop in an autonomous LLM agent that samples new links, investigates targets in sandboxes, and quarantines or asks a human to confirm deletion.

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

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
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.

<Note>
  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**.
  <br /> <br /> 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.
</Note>

## Setup for the `link-safety-agent`

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](https://discord.gg/iiidev).

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:

<Warning>
  `iii-sandbox` requires hardware virtualization. macOS works on Apple Silicon (M-series). Linux
  works with KVM enabled (`/dev/kvm` readable). Windows requires WSL2.
</Warning>

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
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`:

```yaml config.yaml theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
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:

```yaml config.yaml {12-15} theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
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
```

### Keep quarantine in the agent, not the link worker

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.

## Create the `link-safety-agent` worker

Scaffold the worker the same way you scaffolded `link` in Chapter 1:

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
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`:

<Accordion title="link-safety-agent/src/agent.ts — tool definitions and system prompt">
  ```typescript src/agent.ts theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
  export type Tool = {
    name: "inspect_url" | "quarantine" | "propose_delete" | "allow";
    description: string;
    parameters: Record<string, unknown>;
  };

  export const TOOLS: Tool[] = [
    {
      name: "inspect_url",
      description:
        "Fetch the target URL inside an ephemeral iii-sandbox and return the HTTP status, redirect chain, and a short body excerpt.",
      parameters: { type: "object", properties: { url: { type: "string" } }, required: ["url"] },
    },
    {
      name: "quarantine",
      description:
        "Mark the link malicious. Resolves stop returning a URL; auto-applied, no human review. Use for clear-cut bad targets.",
      parameters: {
        type: "object",
        properties: { reason: { type: "string" } },
        required: ["reason"],
      },
    },
    {
      name: "propose_delete",
      description:
        "Ask a human operator to confirm deletion. The browser admin shows a confirm prompt; the link is only removed if confirmed.",
      parameters: {
        type: "object",
        properties: { reason: { type: "string" } },
        required: ["reason"],
      },
    },
    {
      name: "allow",
      description: "Conclude the investigation; the link is fine. End the turn.",
      parameters: {
        type: "object",
        properties: { reason: { type: "string" } },
        required: ["reason"],
      },
    },
  ];

  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.`;

  // A single tool call from the model's response. harness normalizes provider
  // tool calls into `function_call` content blocks, so we read those.
  export type ToolCall = { id: string; name: Tool["name"]; input: Record<string, unknown> };

  // Pick one function_call block from an assistant message; null if there is none.
  export function pickToolCall(content: unknown): ToolCall | null {
    if (!Array.isArray(content)) return null;
    for (const block of content) {
      if (
        block &&
        typeof block === "object" &&
        (block as { type?: unknown }).type === "function_call" &&
        typeof (block as { id?: unknown }).id === "string" &&
        typeof (block as { function_id?: unknown }).function_id === "string"
      ) {
        const b = block as { id: string; function_id: string; arguments?: Record<string, unknown> };
        return { id: b.id, name: b.function_id as Tool["name"], input: b.arguments ?? {} };
      }
    }
    return null;
  }

  // Read `reason` off a tool call's arguments safely.
  export function reasonOf(input: Record<string, unknown>): string {
    const r = input.reason;
    return typeof r === "string" ? r : "";
  }
  ```
</Accordion>

### 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:

<Accordion title="link-safety-agent/src/stub.ts — deterministic provider stand-in">
  ```typescript src/stub.ts theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
  type ContentBlock = { type: string; [key: string]: unknown };
  type Message = { role: "user" | "assistant" | "function_result"; content: ContentBlock[] };

  // Pull the link's URL out of the first user message, and the latest
  // function_result (an inspect_url response) out of any later ones.
  function lastUserContext(messages: Message[]): { url: string; toolResult: string | null } {
    let url = "";
    let toolResult: string | null = null;
    for (const m of messages) {
      if (m.role === "user") {
        for (const block of m.content) {
          if (block.type === "text" && typeof block.text === "string") {
            const match = block.text.match(/url:\s*(\S+)/);
            if (match) url = match[1];
          }
        }
      }
      if (m.role === "function_result") {
        for (const block of m.content) {
          if (block.type === "text" && typeof block.text === "string") toolResult = block.text;
        }
      }
    }
    return { url, toolResult };
  }

  // Build a function_call block in the same shape harness returns from a provider.
  function functionCall(functionId: string, args: Record<string, unknown>) {
    return {
      type: "function_call",
      id: `stub_${Math.random().toString(36).slice(2, 10)}`,
      function_id: functionId,
      arguments: args,
    };
  }

  export function stubDecide(messages: Message[]): {
    content: unknown;
    stop_reason: string;
  } {
    const ctx = lastUserContext(messages);
    if (ctx.toolResult === null) {
      return {
        content: [functionCall("inspect_url", { url: ctx.url })],
        stop_reason: "function_call",
      };
    }
    if (/malware/i.test(ctx.url)) {
      return {
        content: [functionCall("quarantine", { reason: 'url contains "malware"' })],
        stop_reason: "function_call",
      };
    }
    if (/phishing/i.test(ctx.url)) {
      return {
        content: [functionCall("propose_delete", { reason: 'url contains "phishing"' })],
        stop_reason: "function_call",
      };
    }
    return {
      content: [functionCall("allow", { reason: "no suspicious markers" })],
      stop_reason: "function_call",
    };
  }
  ```
</Accordion>

### Define the `link-safety-agent` worker

`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.)

```typescript src/index.ts theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
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`:

<Accordion title="inspectUrl — probe a URL inside a sandbox">
  ```typescript src/index.ts theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
  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(() => {});
    }
  }
  ```
</Accordion>

`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:

```typescript src/index.ts theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
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:

```typescript src/index.ts theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
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:

<Accordion title="investigate — the tool-calling loop">
  ```typescript src/index.ts theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
  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,
        });
      }
    }
  }
  ```
</Accordion>

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`:

```yaml config.yaml {6-9} theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
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:

```typescript src/index.ts {1,5-15,19-22} theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
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:

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
iii worker add ./link-safety-agent
```

## See it work (stub mode, no API key)

With the engine running, create three links:

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
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.

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
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:

```typescript src/agent.ts theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
name: "inspect_url" | "quarantine" | "propose_delete" | "allow" | "quarantine_burst";
```

```typescript src/agent.ts theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
{
  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:

```typescript src/agent.ts theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
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:

<Accordion title="quarantineBurst — derive the burst from traces, then build, run, and unregister the purge">
  ```typescript src/index.ts theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
  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();
    }
  }
  ```
</Accordion>

Handle the new tool in the loop, alongside the other terminal decisions:

```typescript src/index.ts theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
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:

```typescript src/stub.ts theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
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:

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
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:

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
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:

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
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`:

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
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'`:

```yaml link-safety-agent/iii.worker.yaml theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
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:

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
iii worker restart link-safety-agent
```

<Note>
  `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.
</Note>

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`:

```yaml config.yaml {10-13} theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
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:

```yaml config.yaml {11} theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
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:

```tsx src/App.tsx {2,12-21} theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
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:

```typescript src/index.ts {6} theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
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](/tutorials/linkly/scheduling), you add a `link-sweeper` worker that
expires stale links on a schedule with `iii-cron`.
