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

# Ch. 1: Foundations

> Build the link worker with link::create and link::resolve, then expose it over HTTP.

In this chapter you will build the core of Linkly: a worker that creates short codes and resolves
them back to URLs, callable first from the command line and then over HTTP. By the end you will have
a working web service where `POST /links` mints a short code and `GET /s/:code` redirects to the
original URL.

## Create the project

A iii project is a directory with a `worker-compose.yaml` file that describes your system. Create
one:

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

> [!NOTE]
>
> This project ships with all you need already contained within the project folder. Cases where you
> need to add code will be accomplished by uncommenting the correct block of code.

## Take a look at `worker-compose.yaml`

The project ships with a prebuilt worker-compose.yaml so at this point you don't need to make any
edits to it. When you do make edits they will largely be programmatic and use `compose::*`
functions.

Later in this chapter you'll serve the `link` worker over HTTP (provided by `http`) and stash
short-code → URL mappings in a key-value store (provided by `state`). `worker-compose.yaml` declares
both, along with the `link` worker you write next:

```yaml worker-compose.yaml theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
namespace: default
engine:
  url: ws://127.0.0.1:49134
containers:
  http:
    worker: package://http
    version: "0.21.9"
    config_name: http

  state:
    worker: package://state
    version: "0.22.8"
    config_name: state
    config_override:
      adapter:
        name: kv
        config:
          store_method: in_memory

  link:
    worker: path://./link
```

The `engine:` section is where the engine can be configured, this is useful for when starting a
compose project and the engine with `iii compose --up`.

`containers` store one entry for each worker. Workers can exist as remote packages or local paths.
For example `http` comes from a remote package, while `linkly` will be a path from your project
directory to the local worker.

`state` uses an in-memory store by default, so every restart starts clean. That's what we want for
this chapter. The `config_override` block declares that default explicitly.
[Ch. 3: Persist everything](/docs/tutorials/linkly/persistence.md) will swap this approach for durable
storage.

## Explore the link worker

The `link` directory holds the worker that stores and retrieves short links.

### The worker entrypoints

A worker is a self-contained service. Here, the `link` worker is a Node package but it could be any
language or runtime.

`link/iii.worker.yaml` is the manifest that describes how the worker runs itself:

```yaml iii.worker.yaml theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
name: link
runtime:
  # Base OCI image used as the worker rootfs when virtualized.
  base_image: docker.io/iiidev/node:latest
scripts:
  install: "npm install"
  start: "npm run start"
```

> [!NOTE]
>
> Compose runs the worker from `scripts.start`, but a worker can also be an ordinary service. Any
> process that uses a iii SDK and calls `registerWorker()` is a worker.
>
> Learn more about the [`iii.worker.yaml` manifest](/docs/creating-workers/workers#worker-manifest).

`link/package.json` declares the dependencies. Its `start` script uses `tsx watch`, which runs the
TypeScript source directly and reloads the worker whenever you save a change.

```json package.json theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
{
  "name": "link",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "tsx watch src/index.ts"
  },
  "dependencies": {
    "iii-sdk": "0.23.0",
    "@iii-dev/helpers": "0.23.0",
    "tsx": "^4.22.3"
  },
  "devDependencies": {
    "typescript": "^5.9.3",
    "@types/node": "^24.10.1"
  }
}
```

### The worker entry point

`link/src/index.ts` is the worker's entry point. You'll build it up in a few small steps. All of the
code below is contained within `index.ts`, you can uncomment it rather than copy and pasting.

> [!NOTE]
>
> Pay attention to code blocks that note they will be replaced by future codeblocks. We are building
> this application in much the same way you would in the real world and will be revising prior code
> to add features and functionality as we go.

#### Opening a connection to the engine

`registerWorker` opens the connection to the engine. Add the connection setup and a small helper
that generates random short codes to `index.ts`:

```typescript src/index.ts theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
import { registerWorker } from "iii-sdk";
import { Logger } from "@iii-dev/helpers/observability";

const worker = registerWorker(process.env.III_URL ?? "ws://localhost:49134", {
  workerName: "link",
});
const logger = new Logger();

const CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";

function makeCode(): string {
  let s = "";
  for (let i = 0; i < 6; i++) s += CHARS[Math.floor(Math.random() * CHARS.length)];
  return s;
}
```

#### Add `link::create`

`registerFunction` publishes a function under a name like `link::create` that anything else on the
engine can call. This one stores the mapping by calling `state::set` on the `state` worker through
`worker.trigger`. Worker-to-worker calls always flow through the engine, so the `link` worker
doesn't import anything from `state`; it knows the function name. Append it:

```typescript src/index.ts theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
worker.registerFunction("link::create", async (payload: { url: string; code?: string }) => {
  // Store an absolute URL so the redirect's Location header is absolute, not
  // resolved relative to /s/:code.
  const url = /^https?:\/\//i.test(payload.url) ? payload.url : `https://${payload.url}`;
  const taken = async (c: string) =>
    Boolean(
      await worker.trigger({ function_id: "state::get", payload: { scope: "links", key: c } }),
    );
  let code = payload.code;
  if (code) {
    // A requested code must be free; creating never overwrites an existing link.
    if (await taken(code)) throw new Error(`code "${code}" is taken`);
  } else {
    // A generated code retries until it finds a free one.
    do {
      code = makeCode();
    } while (await taken(code));
  }
  await worker.trigger({
    function_id: "state::set",
    payload: { scope: "links", key: code, value: { url } },
  });
  logger.info("link created", { code, url });
  return { code, url };
});
```

#### Add `link::resolve`

`link::resolve` looks the mapping back up with `state::get`, returning the URL or `null` when the
code is unknown. Append it, with a final log line so you can see the worker come up:

```typescript src/index.ts theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
worker.registerFunction("link::resolve", async (payload: { code: string }) => {
  const stored = await worker.trigger<{ scope: string; key: string }, { url: string } | null>({
    function_id: "state::get",
    payload: { scope: "links", key: payload.code },
  });
  logger.info("link resolved", { code: payload.code, found: !!stored?.url });
  return { url: stored?.url ?? null };
});

logger.info("link worker ready");
```

The `state::set` / `state::get` calls pass a `scope` (`links`) and a `key` (the short code). Scopes
keep different kinds of data in `state` from colliding; later chapters add more.

## Start the project

From the project root, start the engine and Compose project. The workers register their functions
with the engine. `--up` is a `iii compose` flag that starts the engine along with the workers:

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

## Register the worker

`worker-compose.yaml` declares `link` as a `path://` container, so Compose starts it with the rest
of the project. On the Compose/engine output you will see the `link` worker register `link::create`
and `link::resolve`. You can see engine logs by tailing the engine log file with:

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
tail -30 $HOME/.iii/compose/default/engine.log
```

The output will look something like this:

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
[11:54:47.597 AM] [INFO] iii::worker_connections Worker registered
    ├ worker_id: adf46954-461e-450c-961f-ed6fe0cc1e31
    └ ip_address: Some("127.0.0.1")
[11:54:47.609 AM] [INFO] iii::function [REGISTERED] Function link::create
[11:54:47.609 AM] [INFO] iii::function [REGISTERED] Function link::resolve
[11:54:47.697 AM] [INFO] iii-node link worker ready
```

## Call the functions

`iii trigger` invokes a function on the running engine. Create a link with a custom code:

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
iii trigger link::create url=https://iii.dev code=iii
```

```json theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
{
  "code": "iii",
  "url": "https://iii.dev"
}
```

Resolve it back:

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
iii trigger link::resolve code=iii
```

```json theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
{
  "url": "https://iii.dev"
}
```

An unknown code resolves to `null`:

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
iii trigger link::resolve code=nope
```

```json theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
{
  "url": null
}
```

> [!TIP]
>
> You have a working domain worker. `link::create` and `link::resolve` are registered with the
> engine and callable from anywhere within your iii system. Next let's put them behind HTTP so that
> external 3rd party systems could use them.

> [!NOTE]
>
> As you'll see later, unless you're supporting 3rd party systems it isn't necessary to expose
> services over http since iii can even run browser tabs as workers.

## Expose your functions over HTTP

A function becomes an HTTP endpoint when you bind it to an `http` trigger. That trigger type is
served by the `http` worker you added at the start of the chapter.

### Create a function to handle new links

Add `http::create` to `link/src/index.ts`. It validates the request body, calls `link::create`
through the engine with `worker.trigger`, and returns the new link, or `409` when the requested code
is already taken:

```typescript src/index.ts theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
worker.registerFunction("http::create", async (req) => {
  const { url, code } = req.body ?? {};
  if (!url) {
    return {
      status_code: 400,
      body: { error: 'missing "url"' },
      headers: { "Content-Type": "application/json" },
    };
  }
  try {
    const link = await worker.trigger<
      { url: string; code?: string },
      { code: string; url: string }
    >({
      function_id: "link::create",
      payload: { url, code },
    });
    return {
      status_code: 201,
      body: link,
      headers: { "Content-Type": "application/json" },
    };
  } catch (err) {
    // link::create throws when a requested code is already taken.
    return {
      status_code: 409,
      body: { error: err instanceof Error ? err.message : "conflict" },
      headers: { "Content-Type": "application/json" },
    };
  }
});
```

### Bind your create function to a Trigger

In the same file (`link/src/index.ts`) at the end bind `http::create` to `POST /links` with a new
trigger. This Trigger has the `http` worker listen for `POST` requests to `/links` and when it
receives one it will run the function specified by `function_id`.

```typescript src/index.ts theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
worker.registerTrigger({
  type: "http",
  function_id: "http::create",
  config: { api_path: "/links", http_method: "POST" },
});
```

> [!TIP]
>
> This is the first Trigger you've registered yourself. In iii, Triggers control what causes
> something to happen. In this case an http request causes a function to run. Learn more about
> [Using iii / Triggers](/docs/using-iii/triggers.md).

> [!NOTE]
>
> Every function registered comes with its own Trigger which is why `worker.trigger` worked earlier
> without a declaration.

### Mint a link over HTTP

Save the file and the worker reloads with the new route registered. The `http` container listens on
`127.0.0.1:3111` and owns the route below. Now try out your new Trigger:

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

```http theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
HTTP/1.1 201 Created
content-type: application/json

{"code":"demo","url":"https://example.com"}
```

The link sits in `state` now, but `GET /s/demo` has nowhere to go yet. There's no handler; add one
next.

### Create a function to handle redirects

Add `http::redirect` to the bottom of `link/src/index.ts`. It looks up the short code via
`link::resolve`, returns a 404 when there's no match, and a 302 to the original URL otherwise:

```typescript src/index.ts theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
worker.registerFunction("http::redirect", async (req) => {
  const code = req.path_params.code;
  const { url } = await worker.trigger<{ code: string }, { url: string | null }>({
    function_id: "link::resolve",
    payload: { code },
  });
  if (!url) {
    return {
      status_code: 404,
      body: { error: "link not found" },
      headers: { "Content-Type": "application/json" },
    };
  }
  return { status_code: 302, headers: { Location: url } };
});
```

### Bind your redirect function to a Trigger

Like before, bind `http::redirect` to `GET /s/:code` with a new Trigger:

```typescript src/index.ts theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
worker.registerTrigger({
  type: "http",
  function_id: "http::redirect",
  config: { api_path: "/s/:code", http_method: "GET" },
});
```

### Follow the short code

`state` is in-memory in this chapter, so each time the engine restarts the previous link is gone.
Chapter 3 swaps in durable storage. For now create a fresh link and try it out:

```bash theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
curl -i -X POST http://127.0.0.1:3111/links \
  -H 'Content-Type: application/json' \
  -d '{"url":"http://iii.dev/docs/understanding-iii","code":"learn-iii"}'
```

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

```http theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
HTTP/1.1 302 Found
location: http://iii.dev/docs/understanding-iii
```

An unknown code returns `404`:

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

```http theme={"theme":{"light":"catppuccin-latte","dark":"dark-plus"}}
HTTP/1.1 404 Not Found

{"error":"link not found"}
```

## Conclusion

You have built a real link shortener: a domain worker exposed over HTTP, where the same
`link::create` and `link::resolve` functions serve both the command line and the web. Restarting the
engine still clears every link, though: `state` is in-memory until Chapter 3 swaps it for durable
storage.

Next, in [Ch. 2: Observe everything](/docs/tutorials/linkly/observability.md), you will add logs and traces
and watch invocations flow through the engine in the console.


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