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

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

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
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:
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
The tool implementations are short. inspect_url boots a iii-sandbox and runs curl:
src/index.ts
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
propose_delete reuses Chapter 7’s link::request_delete, which routes through the browser admin and only deletes on confirmation:
src/index.ts
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
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
In link-safety-agent/src/index.ts, the subscriber enqueues; a separate consumer runs the loop:
src/index.ts
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:

See it work (stub mode, no API key)

With the engine running, create three links:
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.
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
src/agent.ts
Extend SYSTEM_PROMPT so the model knows when to reach for it:
src/agent.ts
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
Handle the new tool in the loop, alongside the other terminal decisions:
src/index.ts
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
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:
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:
The function the agent built is already gone, unregistered the moment its work finished:

Switch to a real LLM

Set the Anthropic key via auth::set_token:
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
Restart the worker so it picks up the change:
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
Add link::delete to the browser RBAC allowlist so the browser session is allowed to enqueue it:
config.yaml
In the frontend, update src/App.tsx to enqueue the delete when the operator confirms:
src/App.tsx
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

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.