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
Going through the
The agent’s trace becomes
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.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.
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:
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’sauth-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
Keep quarantine in the agent, not the link worker
Thelink-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:
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:
link-safety-agent/src/agent.ts — tool definitions and system prompt
link-safety-agent/src/agent.ts — tool definitions and system prompt
src/agent.ts
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 forprovider::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/stub.ts — deterministic provider stand-in
link-safety-agent/src/stub.ts — deterministic provider stand-in
src/stub.ts
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.)
src/index.ts
inspect_url boots a iii-sandbox and runs curl:
inspectUrl — probe a URL inside a sandbox
inspectUrl — probe a URL inside a sandbox
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
investigate — the tool-calling loop
investigate — the tool-calling loop
src/index.ts
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 tolink.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
link-safety-agent/src/index.ts, the subscriber enqueues; a separate consumer runs the loop:
src/index.ts
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:safety database, and the link is gone from the link worker.
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 oflink::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
SYSTEM_PROMPT so the model knows when to reach for it:
src/agent.ts
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
quarantineBurst — derive the burst from traces, then build, run, and unregister the purge
src/index.ts
src/index.ts
quarantine_burst for campaign
URLs. Add this branch to link-safety-agent/src/stub.ts before the allow fallback:
src/stub.ts
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:link::create calls, sees the cluster, builds safety::purge_window_*, and quarantines the whole
batch:
Switch to a real LLM
Set the Anthropic key viaauth::set_token:
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
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.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’spropose_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
link::delete to the browser RBAC allowlist so the browser session is allowed to enqueue it:
config.yaml
src/App.tsx to enqueue the delete when the operator confirms:
src/App.tsx
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 (callinspect_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.