Skills DirectorySkills Directory
SkillsLearnSecurityCategoriesDocsCommunityBlog
Sign InSubmit Skill
Skills Directory

Security-tested agent skills for Claude, coding agents, and AI workflows.

Directory

  • Browse Skills
  • All Skills A–Z
  • Claude Skills
  • Claude Code Skills
  • Agent Skills
  • Categories
  • Submit a Skill

Learn

  • Learn Hub
  • Install Claude Skills
  • Write SKILL.md
  • Skills vs MCP
  • Directories Compared

Security

  • Security
  • Methodology
  • Secure Claude Skills
  • Security Badges

Company

  • About
  • Community
  • Blog
  • API Docs
  • Advertise

2026 Skills Directory. All rights reserved.

Back to skills

Linkly

ASecurity

engine: workers: {} containers: http: worker: package://api.workers.iii.dev/http version: "0.21.3" config_name: http state: worker: package://api.workers.iii.dev/state version: "0.22.2" config_name: state config_override: adapter: name: kv config: store_method: in_memory ``` The `engine:` section makes this Compose invocation own the engine. Project workers belong only under `containers:`. `state` uses an in-memory store by default, so every restart starts clean. That's what we want for this ...

18,691 stars
0 votes
0 copies
0 views
Added 9/20/2026
developmenttypescriptgobashnodeapi

Works with

terminalcliapi

Security Analysis

A92/100
mediumUses curl or wget to download content
mediumInstalls packages at runtime which could introduce malicious dependencies

Scanned 9/20/2026

Install to Claude Code

$npx -y skills add iii-hq/iii --skill linkly --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Linkly?

Add the live security badge to your README — it updates automatically with every re-scan.

Security grade badge for Linkly
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/iii-hq-linkly-447d7a1c/badge)](https://www.skillsdirectory.com/skills/iii-hq-linkly-447d7a1c)

More formats (shields.io, HTML) on the badges page.

Download Zip
Files
channels.mdx
<!-- generated by iii-skill-render. DO NOT EDIT (changes here are overwritten on the next render). Edit docs/next/tutorials/linkly/foundations.mdx. -->


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 `config.yaml` file that describes your system. Create one:

```bash
iii project init linkly
cd linkly
```

## Add the workers you'll need

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`). Declare both now so they're
ready when the `link` worker reaches for them:

```yaml worker-compose.yaml
# namespace: default
engine:
  workers: {}
containers:
  http:
    worker: package://api.workers.iii.dev/http
    version: "0.21.3"
    config_name: http
  state:
    worker: package://api.workers.iii.dev/state
    version: "0.22.2"
    config_name: state
    config_override:
      adapter:
        name: kv
        config:
          store_method: in_memory
```

The `engine:` section makes this Compose invocation own the engine. Project workers belong only
under `containers:`.

`state` uses an in-memory store by default, so every restart starts clean. That's what we want for
this chapter. A worker's settings live in a per-worker file under `config/`, and the engine only
generates those files the first time it starts, so you'll make that default explicit in
`config/state.yaml` when Compose starts the worker, a few steps from now.

Since the store is in-memory, every restart clears the data we're storing. That's fine here;
[Ch. 3: Persist everything](/tutorials/linkly/persistence) swaps in durable storage.

The exact versions in `worker-compose.yaml` make starts repeatable.

## Create the link worker

Create a TypeScript worker directory inside the project. This worker will handle storing and
retrieving short links:

```bash
mkdir -p link/src && touch link/src/index.ts
```

### Configure the 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. Create it with
the following content:

```yaml iii.worker.yaml
name: link
scripts:
  start: pnpm start
```

<Info>
  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.
  <p>
    Learn more about the [`iii.worker.yaml` manifest](/creating-workers/workers#worker-manifest).
  </p>
</Info>

Create `link/package.json` with this content. The `start` script uses `tsx watch`, which runs the
TypeScript source directly and reloads the worker whenever you save a change.

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

Install the dependencies once:

```bash
cd link && pnpm install && cd ..
```

### Write the worker entry point

`link/src/index.ts` is the worker's entry point. You'll build it up in a few small steps rather than
pasting one large file at once.

<Info>
  The command above created an empty `index.ts`. Add the first snippet below, then append each later
  snippet to the end of the file.
</Info>

#### Open the connection to the engine

`registerWorker` opens the connection to the engine. Replace the template's example code in
`index.ts` with the connection setup and a small helper that generates random short codes:

```typescript src/index.ts
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
worker.registerFunction("link::create", async (payload: { url: string; code?: string }) => {
  const code = payload.code ?? makeCode();
  // 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}`;
  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
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 engine

From the project root, start the engine and Compose project. The workers register their functions
with the engine. `--up` is a `iii compose` flag, as shown in the generated
[CLI reference](/cli-reference/index#iii-compose):

```bash
iii compose --up --namespace linkly --file worker-compose.yaml
```

On this first start, the configuration worker creates `config/state.yaml` and `config/http.yaml`
from each Compose container's defaults and `config_override`. From here on, edit a worker's settings
in its `config/<worker>.yaml` file; the worker hot-reloads supported changes.

## Make the in-memory store explicit

Now that Compose generated `config/state.yaml`, open it. The `config_override` you declared is
present under `value`:

```yaml config/state.yaml
id: state
name: State
value:
  triggers_enabled: true
  adapter:
    name: kv
    config:
      store_method: in_memory
```

Because the store is in-memory, every restart clears the data. That's fine here; Chapter 3 swaps in
durable storage.

## Register the worker

Keep the engine/Compose terminal open. From another terminal at the root `linkly` directory, add the
local `link` worker. The `-n` flag is the documented short form of `--namespace` for
[`iii trigger`](/cli-reference/index#iii-trigger):

```bash
iii trigger -n linkly compose::add worker=./link
```

On the Compose/engine output you will see the `link` worker register `link::create` and
`link::resolve`. The output will look something like this:

```bash
[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
iii trigger -n linkly link::create url=https://iii.dev code=iii
```

```json
{
  "code": "iii",
  "url": "https://iii.dev"
}
```

Resolve it back:

```bash
iii trigger -n linkly link::resolve code=iii
```

```json
{
  "url": "https://iii.dev"
}
```

An unknown code resolves to `null`:

```bash
iii trigger -n linkly link::resolve code=nope
```

```json
{
  "url": null
}
```

<Check>
  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.
</Check>
<Info>
  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.
</Info>

## 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 the bottom of `link/src/index.ts`. It validates the request body, calls
`link::create` through the engine with `worker.trigger`, and returns the new link:

```typescript src/index.ts
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" },
    };
  }
  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" },
  };
});
```

### 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
worker.registerTrigger({
  type: "http",
  function_id: "http::create",
  config: { api_path: "/links", http_method: "POST" },
});
```

<Check>
  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](/using-iii/triggers).
</Check>

<Info>
  Every function registered comes with its own Trigger which is why `worker.trigger` worked earlier
  without a declaration.
</Info>

### Mint a link over HTTP

Save the file and the worker reloads with the new route registered. In this project,
`config/http.yaml` configures the `http` container to listen on `127.0.0.1:3111`. This Compose
setup does not start a separate in-process engine HTTP server. The `http` container provides the
project HTTP API and owns the route below. Now try out your new Trigger:

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

```http
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
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
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
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
curl -i http://127.0.0.1:3111/s/learn-iii
```

```http
HTTP/1.1 302 Found
location: http://iii.dev/docs/understanding-iii
```

An unknown code returns `404`:

```bash
curl -i http://127.0.0.1:3111/s/missing
```

```http
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](/tutorials/linkly/observability), you will add logs and traces
and watch invocations flow through the engine in the console.

Attribution

iii-hqiii-hq
View sourceMore from iii-hq →
SSkills DirectorySkills Directory

Know which skills are safe — weekly.

Best new skills + every skill we flagged as malicious. From the team that scanned 103,619.

Join free

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments (0)

No comments yet. Be the first to comment!

SSkills DirectorySkills Directory

Know which skills are safe — weekly.

Best new skills + every skill we flagged as malicious. From the team that scanned 103,619.

Join free

Related Skills

Browser Extension Developer

Use this skill when developing or maintaining browser extension code in the `browser/` directory, including Chrome/Firefox/Edge compatibility, content scripts, background scripts, or i18n updates.

281612 votes

Seo Optimizer

SEO optimization with keyword analysis, readability assessment, technical validation, content quality. Use for search rankings, blog posts, content audits, or encountering keyword density, readability scores, meta tags, schema markup errors.

2132 votes

Google Official Seo Guide

Official Google SEO guide covering search optimization, best practices, Search Console, crawling, indexing, and improving website search visibility based on official Google documentation

1862 votes

Tanstack Start

Build a full-stack TanStack Start app on Cloudflare Workers from scratch — SSR, file-based routing, server functions, D1+Drizzle, better-auth, Tailwind v4+shadcn/ui. Use whenever the user mentions TanStack Start, asks to scaffold a full-stack Cloudflare app with SSR, wants an SSR dashboard, or asks for a React 19 + Cloudflare Workers app with file-based routing and server functions — even if they don't name TanStack Start specifically. No template repo — Claude generates every file fresh per ...

9881 votes

Pentest

PTES-aligned adversarial security audit for backend, frontend, and mobile applications. Produces a CVSS-scored Hacker Report with verified PoCs and phased remediation.

5491 votes
View all in development →