- Composability
- Observability
- Discoverability
- Extensibility
Set up the agent
Create the project from the agentic scaffold: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:
.env, you can use one command to start the iii engine, the project, and the
agentic stack you’ll use to develop it:
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: alink::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.
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:
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.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.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:
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.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:
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 aclicks stream via the stream worker.
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:
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.{ 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.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.