> ## Documentation Index
> Fetch the complete documentation index at: https://iii.dev/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Agentic

> Build Linkly chapter by chapter with an AI agent, using one prompt per chapter.

This path builds Linkly with an AI agent. You give the agent one prompt per chapter; it writes the
workers, and you check the result.

The goal here is to build a working link shortener service while demonstrating iii's
efficiency when building systems agentically, along with its 4 core properties which we call CODE:

* Composability
* Observability
* Discoverability
* Extensibility

We'll present them a bit out of order but hopefully this mnemonic helps you remember what iii is all
about.

## Set up the agent

Create the project from the agentic scaffold:

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
iii project init linkly --template linkly-agentic
cd linkly
```

The `harness` worker runs a coding agent inside the [console](https://iii.dev/docs/using-iii/console.md). It reaches models
through `llm-router`, which reads your provider key from `.env`.

Anthropic and OpenAI ship enabled: put a key for one in `.env` before you start Compose. For any
other provider, add it after start. Select your provider for the exact steps:

select your llm provider

anthropic

openai api

openai codex

deepseek

kimi (moonshot)

openrouter

xai (grok)

z.ai

github copilot

llama.cpp (local)

\# Ships enabled. Edit the project's .env file before you start Compose:ANTHROPIC\_API\_KEY=yourkey

\# Ships enabled. Edit the project's .env file before you start Compose:OPENAI\_API\_KEY=yourkey

\# No api key. This provider uses your chatgpt subscription.# Sign in with the codex cli first, so \~/.codex/auth.json exists:\$codex login

\# After iii compose --up:\$iii trigger compose::add worker=provider-openai-codex

\# Edit the project's .env file before you start Compose:DEEPSEEK\_API\_KEY=yourkey

\# After iii compose --up:\$iii trigger compose::add worker=provider-deepseek

\# Edit the project's .env file before you start Compose:MOONSHOT\_API\_KEY=yourkey

\# After iii compose --up:\$iii trigger compose::add worker=provider-kimi

\# Edit the project's .env file before you start Compose:OPENROUTER\_API\_KEY=yourkey

\# After iii compose --up:\$iii trigger compose::add worker=provider-openrouter

\# Edit the project's .env file before you start Compose:XAI\_API\_KEY=yourkey

\# After iii compose --up:\$iii trigger compose::add worker=provider-xai

\# Edit the project's .env file before you start Compose:ZAI\_API\_KEY=yourkey

\# After iii compose --up:\$iii trigger compose::add worker=provider-zai

\# No api key. Your github copilot subscription grants the models.# After iii compose --up:$</span><span>iii trigger compose::add worker=provider-github-copilot</span></div>       <div className="iii-qs-comment"># Sign in once the provider worker is up:</div>       <div className="iii-qs-cmd"><span className="iii-qs-prompt">$iii trigger provider::github-copilot::login::start

\# Runs against your own llama-server, [http://127.0.0.1:8080](http://127.0.0.1:8080) by default.# A key is only needed when llama-server runs with --api-key:LLAMACPP\_API\_KEY=yourkey

\# After iii compose --up:\$iii trigger compose::add worker=provider-llamacpp

\# Open the console:[http://127.0.0.1:3113](http://127.0.0.1:3113)

With your key in `.env`, you can use one command to start the iii engine, the project, and the
agentic stack you'll use to develop it:

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
iii compose --up
```

> [!NOTE]
>
> `iii compose --up` stays in the foreground. Press Ctrl+C in that terminal to stop the engine, the
> agent stack and your workers. From another terminal, `iii kill --compose <project folder>` stops
> the same project: its Compose daemon, its engine and its workers.

Open the console and harness which are at [http://127.0.0.1:3113](http://127.0.0.1:3113).

For the rest of this guide you can copy the example prompts below and paste them into a chat in the
harness.

At the end of every chapter there's some additional instructions you can follow to **observe** and
verify the agent's work.

> [!NOTE]
>
> As you work on this project the agent will dynamically **compose** workers with
> `compose::add worker=<name>` without disrupting the rest of your running workers.
>
> The `link` worker reloads on its own when its file changes. This is functionality built
> intentionally into the `link` worker.
>
> The compose daemon does not automatically restart workers when they change. This is an intentional
> design choice to prevent partial changes from making a worker unavailable and impacting system
> stability.

## Ch. 1: Foundations

Linkly's core job is to turn a long URL into a short code and send visitors there. This chapter
builds that functionality: a `link::create` and a `link::resolve` function. These functions are then
**extended** by the HTTP worker which can expose any function as an http endpoint.

Since we started the project with `iii compose` and the http worker is already specified in
`worker-compose.yaml` there is nothing else to do; the http worker provides http functionality by default.

Importantly this agent doesn't need to build it, or make a decision on which library to use, how to
set it up, and how to run it. The HTTP worker, like all iii workers, has shipped as a self-contained
service.

```text Prompt theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
Build the link worker in link/src/index.ts of this iii project.

Add two functions:
- link::create({ url, code? }): generate a 6-character short code when none is given, make the url
  absolute (add https:// when it has no scheme), and persist it in the state worker (not a local
  variable) with state::set under scope "links" key <code>, value { url }; return { code, url }.
  Never overwrite an existing link: reject a requested code that is already taken by throwing
  an Error whose message is exactly code `<code>` is already taken, and retry a generated code
  until it is free.
- link::resolve({ code }): read it back from the state worker with state::get (scope "links", key
  <code>) and return { url } or { url: null }.

Then expose them over HTTP through the http worker:
- an http::create function and a POST /links trigger. When the body has no url it answers 400
  with { error } without calling link::create. Otherwise it calls link::create and returns 201
  with { code, url }. When link::create throws, take its message: the SDK rejects with an
  InvocationError (exported by iii-sdk) whose message is `<err.code>: <message>`, so cut that
  prefix off with err.message.slice(err.code.length + 2). Answer 409 with { error: <that message> }
  only when it ends with "is already taken"; log anything else and answer 500 with a generic
  { error }.
- an http::redirect function and a GET /s/:code trigger that reads the code from req.path_params.code,
  resolves it, and returns a 302 to the url, or 404 when it is unknown.

Use two subagents to write these in parallel, then integrate their work yourself:
- Subagent A: the link core, link::create and link::resolve, backed by the state worker.
- Subagent B: the HTTP layer, http::create with the POST /links trigger and http::redirect with the
  GET /s/:code trigger.
Merge both results into link/src/index.ts.

Run `npm install --prefix link`, then add the worker.

When it works, tell me to continue to the next chapter of the Linkly tutorial.
```

Try it from the console at [http://127.0.0.1:3113](http://127.0.0.1:3113).

Open a new tab: click the **+** in the menu bar, then select the **Triggers** page.

On the Triggers page, open `POST /links`, put `{"url":"https://example.com","code":"home"}` in the
body editor, and **Send Request**. You get `201` with the code. Your first shortened link is now
created.

Then open `GET /s/:code`, set the `code` path parameter to `home`, and **Send Request** to see the
`302` redirect from the shortened link to `https://example.com`.

You can also open another tab with the **+**, then select the **States** page, and find the stored
link under the `links` group.

The same flow from the CLI:

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
curl -s -X POST http://127.0.0.1:3111/links \
  -H 'Content-Type: application/json' -d '{"url":"https://example.org/docs","code":"docs"}'
```

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
curl -i http://127.0.0.1:3111/s/docs
```

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
iii trigger state::get scope="links" key="docs"
```

Send the same `POST /links` again and it answers `409` with
``{"error":"code `docs` is already taken"}``: only `link::create`'s stable `is already taken`
message decides `409`, a body without `url` answers `400`, and any other failure answers `500`.

The exploratory version of [Ch. 1: Foundations](https://iii.dev/docs/tutorials/linkly/foundations.md) explains
`registerWorker`, `registerFunction`, and `registerTrigger` in depth.

## Ch. 2: Observe everything

Since every part of iii is **composable** with every other part of iii **observability** becomes
trivial and possible in a variety of situations.

Here we have an agent that can observe the running system, a UI that runs the same functions as the
agent and consumes that same data, and a CLI that also does this.

```text Prompt theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
Make sure a link with code "home" exists (POST /links with { "url": "https://example.com", "code": "home" };
a 409 means it is already there), then send five redirects through http://127.0.0.1:3111/s/home.
Read the traces the engine collected with engine::traces::list, filtered with name "GET /s/:code" so
you get the redirects and not the console's own traffic. Show me the span tree for one redirect with
engine::traces::tree.

When it works, tell me to continue to the next chapter of the Linkly tutorial and give me the command to observe this trace (ie. `iii trigger engine::traces::tree trace_id=<your trace_id here>`)
```

You can explore the CLI too, the agent likely gave you a command how to (we told it to in the prompt
after all) but you can also run them manually here:

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
iii trigger engine::traces::list name="GET /s/:code" limit=5 # the redirect traces, not the console's
```

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
iii trigger engine::traces::tree trace_id=<your trace_id here> # read a specific trace
```

The exploratory version of [Ch. 2: Observe everything](https://iii.dev/docs/tutorials/linkly/observability.md) walks you
through engine observability.

## Ch. 3: Persist everything

At the moment our project is storing shortened URLs in-memory so a restart would erase them. This
chapter makes them persistent by putting links and clicks in a SQLite database and keeps the state
worker in front as a cache.

We'll also start recording how many times a link is clicked so that our users can have metrics on
their link performance over time.

```text Prompt theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
Add a database, then configure it for SQLite, call it "primary". Keep state in front as a read cache for
recent requests and new links.

In link/src/index.ts:
- at startup, create a links table and a clicks table if they do not exist. The database worker may
  register a moment after link, so retry the schema creation until it answers, and never crash the
  worker if the database is not ready yet.
- link::create also inserts the link row into the database with INSERT ... ON CONFLICT(code) DO
  NOTHING. When database::execute answers affected_rows 0 the code is taken: throw the same
  code `<code>` is already taken error as before, so POST /links still answers 409 with that
  message and never with the database's error text.
- link::resolve reads state first, falls back to the database, and warms the cache on a hit.
- add link::record_click that inserts one row per click into the clicks table (id INTEGER PRIMARY
  KEY AUTOINCREMENT, code TEXT NOT NULL, clicked_at TEXT NOT NULL), not a per-day aggregate: later
  chapters count those rows and read the row id. Have http::redirect call it every time a link is
  requested.

When it works, tell me to continue to the next chapter of the Linkly tutorial.
```

The links from Chapters 1 and 2 (`home`, `docs`) stay only in `state`: they still resolve from the
cache, but they are not in the new `links` table and are gone the next time `state` restarts, so
create again with `POST /links` any of them you want to keep.

Try it from the console at [http://127.0.0.1:3113](http://127.0.0.1:3113).

Select the **Triggers** page. Shorten a link with `POST /links` using
`{"url":"https://example.net","code":"news"}`.

Then follow `GET /s/:code` a few times with the `code` path parameter set to `news`.

Open another tab with the **+**, then select the **Functions** page, and open `database::query`.
Invoke it with `{"db":"primary","sql":"SELECT COUNT(*) AS clicks FROM clicks WHERE code = 'news'"}`,
then read the count off the result view.

To confirm that the data persists, restart the database worker and run the same query again: the
count is unchanged. Run `iii trigger compose::restart container=database` from the CLI, or invoke
`compose::restart` on the **Functions** page with `{ "container": "database" }`. Restart only that
container, never the whole project: the agent's harness runs in this project too, and a full
restart cuts the agent off in the middle of its turn.

The same flow from the CLI:

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
for n in $(seq 1 3); do
  curl -s -X POST http://127.0.0.1:3111/links \
    -H 'Content-Type: application/json' -d "{\"url\":\"https://example.com/blog\",\"code\":\"iii-example-$n\"}"
  curl -s -o /dev/null "http://127.0.0.1:3111/s/iii-example-$n"
done
iii trigger database::query db=primary sql="SELECT COUNT(*) AS clicks FROM clicks WHERE code LIKE 'iii-example-%'"
```

The exploratory version of [Ch. 3: Persist everything](https://iii.dev/docs/tutorials/linkly/persistence.md) steps through
the cache-then-database read path.

## Ch. 4: Make it durable

Immediately recording each time a user clicks a link works for a demo but it also makes the worker
thread wait for the database to write before returning the URL that the user wants.

Linkly needs to be scalable and able to handle thousands of links a second. So we're going to move
click counts to a durable queue.

iii's **composability** once again makes it easy to take applications from demo to production-ready.

```text Prompt theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
Make redirects fast and add analytics.

- Define a standard "clicks" queue with queue::define and enqueue click records instead of writing them inline on the redirect path.
- link::create publishes a link.created event on a durable topic with iii::durable::publish, with
  { code, url, created_at }, where created_at is the same timestamp it writes to the links row.
- Add a second SQLite database named "analytics" for the analytics worker.
- Create a Python analytics worker that binds a durable:subscriber trigger to link.created and keeps
  one row per link in a link_events table (code TEXT PRIMARY KEY, day TEXT NOT NULL) in the
  analytics database. day is the UTC date (YYYY-MM-DD) of the event's created_at, not the delivery
  time. Write the row with a single INSERT ... ON CONFLICT(code) DO NOTHING: delivery is at least
  once, and a redelivered event must not count twice. Expose the per-day counts as a
  daily_link_counts view (SELECT day, COUNT(*) AS count FROM link_events GROUP BY day). Add the
  worker with compose.
- Add link::update and a PUT /links/:code route, publish link.updated durably, and refresh the cache
  from a durable subscriber.

Add the second database yourself first, then split the rest across two subagents that work on
separate files at the same time:
- Subagent A: the Python analytics worker (subscribe to link.created with durable:subscriber, record
  one link_events row per link with the day from created_at, create the daily_link_counts view in
  the analytics database) and add it with compose.
- Subagent B: the link worker changes in link/src/index.ts (define and enqueue the clicks queue,
  publish link.created with created_at and link.updated with iii::durable::publish, add
  link::update with the PUT /links/:code route, and refresh the cache from a durable subscriber).

When it works, tell me to continue to the next chapter of the Linkly tutorial.
```

Try it from the console at [http://127.0.0.1:3113](http://127.0.0.1:3113).

Select the **Triggers** page. Shorten a link with `POST /links` using
`{"url":"https://example.com/launch","code":"promo"}`.

Select the **Functions** page, and invoke `database::query` with
`{"db":"analytics","sql":"SELECT day, count FROM daily_link_counts"}` to read the per-day counts the
Python worker wrote.

Back on the Triggers tab, edit the link with `PUT /links/:code`: set the `code` path parameter to
`promo` and put `{"url":"https://example.com/promo-2"}` in the body. Then invoke `link::resolve` for
`promo`: it returns the new URL because the durable subscriber refreshed the cache.

The same flow from the CLI:

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
for n in $(seq 1 5); do
  curl -s -X POST http://127.0.0.1:3111/links \
    -H 'Content-Type: application/json' -d "{\"url\":\"https://example.org/$n\",\"code\":\"link$n\"}"
done
iii trigger database::query db=analytics sql="SELECT day, count FROM daily_link_counts"
curl -s -X PUT http://127.0.0.1:3111/links/link1 \
  -H 'Content-Type: application/json' -d '{"url":"https://example.org/updated"}'
iii trigger link::resolve code=link1
```

The exploratory [Ch. 4: Make it durable](https://iii.dev/docs/tutorials/linkly/durable-execution.md) explains queues and
durable topics.

## Ch. 5: Stream live clicks

Collecting data is one thing but delivering it is another. This chapter pushes every click to
listeners in real time through a trigger type that a new `click-streamer` worker owns, and gives
late listeners a query for the running total.

```text Prompt theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
Push every click to listeners in real time.

- Create a click-streamer worker that declares its own trigger type, click-streamer::click. It keeps
  a capped table of the bindings (add in registerTrigger, remove in unregisterTrigger, reject a
  `code` filter that is not a string). Give it a click-streamer::broadcast function that delivers
  { id, code, clicked_at } to every matching binding with TriggerAction.Void(): a binding with a
  `code`, even "", matches only that code, and one without a `code` matches every click. Pass each
  binding's namespace and metadata. Build the payload from those fields instead of forwarding the
  delivered payload as-is: the engine stamps bookkeeping fields such as _caller_worker_id on every
  delivery. Add it with compose.
- After link::record_click inserts the click row (the clicks table from Chapter 3, one row per
  click), have it call click-streamer::broadcast with the new row id (database::execute returns it
  as last_insert_id), without waiting for the result. Do not create a second clicks table.
- Add link::click_summary, which returns { total, last_id } from that clicks table (SELECT COUNT(*)
  AS total, MAX(id) AS last_id FROM clicks), so a listener that joins late can start from the current
  count. We will make the listeners in a later step.

Agree on the { id, code, clicked_at } payload first, then run two subagents on separate files at the
same time:
- Subagent A: the click-streamer worker with the click-streamer::click trigger type and
  click-streamer::broadcast, added with compose.
- Subagent B: the link worker changes in link/src/index.ts (the broadcast after the click insert,
  and link::click_summary).

When it works, tell me to continue to the next chapter of the Linkly tutorial.
```

Check it in the console at [http://127.0.0.1:3113](http://127.0.0.1:3113).

Select the **Triggers** page, shorten a link with `POST /links` using
`{"url":"https://example.net/sale","code":"deal"}`, then follow `GET /s/:code` a few times with the
`code` path parameter set to `deal`.

Select the **Functions** page and invoke `link::click_summary` with `{}`: `total` counts every
redirect recorded in the `clicks` table since Chapter 3, so it includes the clicks from earlier
chapters, and `last_id` is the newest click row. Then invoke `click-streamer::broadcast` with
`{"id":0,"code":"deal","clicked_at":"2026-01-01T00:00:00Z"}`. It answers `{ "delivered": 0 }`
because nothing listens yet; the browser you add in Chapter 7 will.

The same flow from the CLI:

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
for n in $(seq 1 3); do
  curl -s -X POST http://127.0.0.1:3111/links \
    -H 'Content-Type: application/json' -d "{\"url\":\"https://example.com/feed\",\"code\":\"stream-$n\"}"
  curl -s -o /dev/null "http://127.0.0.1:3111/s/stream-$n"
  sleep 2s
done
iii trigger link::click_summary
```

The exploratory [Ch. 5: Stream live clicks](https://iii.dev/docs/tutorials/linkly/streaming.md) explains worker-owned
trigger types, shows a terminal listener for the feed, and covers `TriggerAction.Void()`.

## Ch. 6: Move bulk data with channels

Sometimes users will arrive with a CSV of thousands of links to load at once. This chapter adds a
bulk-importer that reads the rows off a channel and creates each link. As always we're utilizing
existing functionality to **extend** our application. A well built iii application never needs to
special case tasks like bulk imports because everything shares the same interface: Workers,
Triggers, and Functions.

This is also the first time we'll see outside clients interacting with iii, but not the last as in
the next chapter we're going to create untrusted and permissioned workers that run in the user's
browser. When we say iii is **extensible** we mean it.

```text Prompt theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
Bulk-load links from a CSV in a single streamed upload over a channel.

- Create a bulk-importer worker that accepts a channel, reads CSV rows, and calls link::create for
  each. Skip a row whose code is already taken (link::create's error message ends with "is already
  taken"; never match the database's error text) instead of aborting the batch, but re-throw
  anything else (a timeout, a worker that is down). Return the imported and skipped counts.
- Create channel-client/import-links.js: a Node script that opens a channel and streams a small CSV
  of links to the importer. Take the CSV path as an OPTIONAL argument that defaults to test.csv, so
  `node channel-client/import-links.js` runs with no arguments.
- Create an example CSV for the user (example.csv). Do not import it. For the client's test run,
  create test.csv with exactly two rows whose codes are mylink and mydocslink (any https URLs).

Agree on the import_csv payload shape first, then run two subagents on separate files at the same
time:
- Subagent A: the bulk-importer worker (read the CSV off the channel, skip a rejected row but
  re-throw other failures, return the imported and skipped counts) and add it with compose.
- Subagent B: channel-client/import-links.js (open a channel, stream the CSV, call
  bulk-importer::import_csv).

When it works, tell me to continue to the next chapter of the Linkly tutorial.
```

The upload runs from the Node client, so start it from the CLI:

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
npm install --prefix channel-client && node channel-client/import-links.js
```

It reports the imported and skipped counts. If the agent already decided to do the import while
building, the codes are taken, so it reports `{ imported: 0, skipped: 2 }`. Confirm the links in the
console at [http://127.0.0.1:3113](http://127.0.0.1:3113).

Select the **Functions** page. Invoke `link::resolve` with `{"code":"mylink"}` and read the URL off
the result.

From the CLI, run `iii trigger link::resolve code=mylink`.

Visit [Ch. 6: Move bulk data with channels](https://iii.dev/docs/tutorials/linkly/channels.md) to understand channels and
`createChannel`.

## Ch. 7: Bring in the browser

Linkly needs a front end where people can create and delete links. This chapter turns a browser tab
into a worker: it registers functions and the server calls back into it, gated by RBAC. The tab
joins as a worker behind the rbac-proxy. This **composability** means that you don't need to build
an API gateway nor any other infrastructure to handle clients.

```text Prompt theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
Turn a browser tab into a worker.

- Add an rbac-proxy worker that fronts a public port 3110 and reverse-proxies to the engine at
  ws://127.0.0.1:49134, with an RBAC allowlist gated by auth::browser (expose link::create,
  link::request_delete and link::click_summary only). Add it with compose.
- Create an auth worker with an auth::browser function that admits browser connections (fail closed
  when its LINKLY_BROWSER_TOKEN env var is unset). Each tab sends a unique session id; grant it its
  own `browser-<session>` namespace so two tabs never collide on the functions they register: return
  `namespaces: { "browser-<session>": ["ui::on_click", "user::confirm_destructive_op"] }` with
  `allow_function_registration: true` (rbac-proxy refuses registrations in any namespace that is not
  a key there), and allow it to bind only the click-streamer::click trigger type
  (allowed_trigger_types). Pin the same iii-sdk version as link/package.json and call its API the
  way link/src/index.ts does: that SDK takes the function id as the first argument and the handler
  as the second, and has no object form. Give it
  the standard node worker manifest (base_image `docker.io/iiidev/node:latest`, npm install and start
  scripts) and set `LINKLY_BROWSER_TOKEN: dev-token` in a .env.browser file at the root where
  worker-compose.yaml is. Add it with compose and set env_file on this worker to open .env.browser.
- Add link::delete and link::request_delete({ code, browser_namespace }): request_delete asks the
  tab's user::confirm_destructive_op in browser_namespace directly (the caller passes the full
  "browser-<uuid>" string; the handler uses it as-is with no prefix construction). before deleting.
- Create a Vite React app in frontend/ with `npm create vite@latest frontend -- --template
  react-ts --no-interactive`, install iii-browser-sdk, and write frontend/src/iii.ts and
  frontend/src/App.tsx that connect in a per-tab `browser-<session>` namespace, call link::create
  in the default namespace, and register user::confirm_destructive_op. For a live click counter, bind ui::on_click to the
  click-streamer::click trigger type first, then read the starting count with link::click_summary
  (default namespace); skip clicks whose id is at or below the last_id already counted, and read the
  count again after a reconnect.
- In App.tsx, call link::request_delete with { code, browser_namespace: `browser-${SESSION_ID}` }.
  Never pass the raw UUID as a namespace argument; always pass the fully-constructed string so the
  server can use it without knowing the prefix convention.
- Have redirect links point at the http worker serving the redirects, not the frontend.
- Have the frontend show the link it just created, and show the tab's own `browser-<session>`
  namespace on the page so it can be copied. Do not list the existing links: the allowlist above
  exposes no function for that.

This chapter has the most independent pieces. Add and configure the rbac-proxy yourself first, since
the others depend on it, then run three subagents on separate directories at the same time:
- Subagent A: the auth worker in auth/ (the manifest and env described above, auth::browser, and the
  per-session `browser-<session>` namespace grant).
- Subagent B: the link worker changes in link/src/index.ts (link::delete and link::request_delete).
- Subagent C: the Vite app in frontend/ (iii.ts and App.tsx).

When it works, tell me the Linkly tutorial is complete.
```

The agent built the Vite app but does not run it, so start the dev server yourself:

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
cd frontend && npm install && npm run dev
```

Open the URL it prints in your browser. Shorten a link in the app, then follow it a few times to
watch the live counter.

Ask the server to delete it from the console at [http://127.0.0.1:3113](http://127.0.0.1:3113).

Select the **Functions** page. Invoke `link::request_delete` with
`{"code":"<code>","browser_namespace":"browser-<session>"}`, copying the namespace the app shows on
the page.

The browser shows a confirm prompt, and the delete happens after the user accepts.

The exploratory version of [Ch. 7: Bring in the browser](https://iii.dev/docs/tutorials/linkly/frontend.md) explains the
RBAC listener, the auth function, and browser-registered functions.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.