Skip to main content
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:
The harness worker runs a coding agent inside the console. 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
# 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 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:
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:
Open the console and harness which are at 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.
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.
Try it from the console at 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:
The exploratory version of Ch. 1: Foundations 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.
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:
The exploratory version of Ch. 2: Observe everything 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.
Try it from the console at 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. You can even restart the entire system to confirm that data is persisting. Run: iii trigger compose::restart --namespace default from the CLI or run it from within the Console by navigating to the Function tab, selecting compose::restart and giving it the payload: { "namespace": "default" } The same flow from the CLI:
The exploratory version of Ch. 3: Persist everything 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.
Try it from the console at 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:
The exploratory Ch. 4: Make it durable explains queues, durable pub/sub, and regular pub/sub.

Ch. 5: Stream live clicks

Collecting data is one thing but delivering it is another. This chapter broadcasts every click to subscribers in real time through a clicks stream via the stream worker.
Check it in the console at 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, invoke stream::list with {"stream_name":"clicks","group_id":"all"}, and read the click messages the stream collected. Each redirect is a new message row. The same flow from the CLI:
The exploratory Ch. 5: Stream live clicks explains streams and 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.
The upload runs from the Node client, so start it from the CLI:
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. 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 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.
The agent built the Vite app but does not run it, so start the dev server yourself:
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. 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 explains the RBAC listener, the auth function, and browser-registered functions.