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.
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.
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:
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:
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.
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:
link-safety-agent/src/agent.ts — tool definitions and system prompt
src/agent.ts
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 : "";}
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:
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:
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:
investigate — the tool-calling loop
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.
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:
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:
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.
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:
{ 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:
quarantineBurst — derive the burst from traces, then build, run, and unregister the purge
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:
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:
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.
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$idone
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:
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':
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.
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:
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.