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

iii-worker-manager: host: 127.0.0.1 port: 49134 iii-worker-manager#browser: host: 127.0.0.1 port: 3110 rbac: auth_function_id: auth::browser expose_functions: - match("link::create") - match("link::request_delete") - match("stream::*") ``` Restart the project so Compose can materialize the changed engine configuration: ```bash iii compose --up --namespace linkly --file worker-compose.yaml ``` `expose_functions` is an allowlist of which functions a browser session can call. `auth_function_id` ...

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

Works with

cliapi

Security Analysis

A96/100
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-bc5feabe/badge)](https://www.skillsdirectory.com/skills/iii-hq-linkly-bc5feabe)

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/frontend.mdx. -->


In this chapter the browser becomes a worker. It connects to the engine over WebSocket, calls
`link::create` directly (no REST API Gateway), subscribes to the live click stream to update a
counter, and registers a `user::confirm_destructive_op` function the server calls when a delete
needs a human's go-ahead.

## Add the workers

A browser worker connects through `iii-worker-manager`'s RBAC-gated listener, separate from the
trusted port your local workers use. We'll encapsulate the authentication logic in an `auth` worker
to gate those connections, so scaffold it the same way you scaffolded `link` in Chapter 1:

<Note>
  If you're continuing from the previous channel you might still be in the `test-channels` folder.
  Make sure to `cd ..`!
</Note>

```bash
mkdir -p auth/src
```

Create the worker manifest and package metadata:

```yaml auth/iii.worker.yaml
name: auth
scripts:
  start: pnpm start
```

```json auth/package.json
{
  "name": "auth",
  "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"
  }
}
```

```bash
cd auth && pnpm install && cd ..
```

## Run two listeners

The engine's port at `49134` is the **trusted** listener; local workers (link worker, analytics
worker) connect there. The browser must not. Stop the running Compose process, then add two
`iii-worker-manager` entries under `engine.workers`: the trusted one and an RBAC-gated instance on
`3110` for browsers:

```yaml worker-compose.yaml
engine:
  workers:
    # Trusted listener for local workers.
    iii-worker-manager:
      host: 127.0.0.1
      port: 49134

    # Browser-facing listener. The auth function gates every connection; only
    # the functions in `expose_functions` are reachable from sessions it admits.
    iii-worker-manager#browser:
      host: 127.0.0.1
      port: 3110
      rbac:
        auth_function_id: auth::browser
        expose_functions:
          - match("link::create")
          - match("link::request_delete")
          - match("stream::*")
```

Restart the project so Compose can materialize the changed engine configuration:

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

`expose_functions` is an allowlist of which functions a browser session can call. `auth_function_id`
names a function `iii-worker-manager` invokes on every connection to admit or reject it; you write
that next.

## Gate connections with an auth function

The `auth` worker owns connection gating, so the `link` worker stays focused on links.
`auth::browser` runs once per browser connection: it receives the request's `headers`,
`query_params`, and `ip_address`, and returns the session's permissions (allow/deny additions,
arbitrary context). Throw to reject. Create `auth/src/index.ts`:

```typescript auth/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: "auth",
});
const logger = new Logger();

worker.registerFunction(
  "auth::browser",
  async (input: {
    headers: Record<string, string>;
    query_params: Record<string, string[]>;
    ip_address: string;
  }) => {
    const token = input.query_params.token?.[0];
    if (!token || token !== (process.env.LINKLY_BROWSER_TOKEN ?? "dev-token")) {
      throw new Error("unauthorized");
    }
    return {
      allowed_functions: [],
      forbidden_functions: [],
      allow_trigger_type_registration: false,
      allow_function_registration: true,
      context: { source: "browser" },
    };
  },
);

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

A real deployment would look the token up in a session store; the shape stays the same. The token
travels in a query parameter because browsers cannot send custom WebSocket headers.

The Compose daemon exposes `compose::add`. Target the daemon serving the `linkly` namespace:

```bash
iii trigger compose::add --namespace linkly worker=./auth
```

## Add a server-initiated delete

First give the `link` worker a `link::delete` that removes a link from both the database and the
`state` cache. Add it to `link/src/index.ts`:

```typescript link/src/index.ts
worker.registerFunction("link::delete", async (payload: { code: string }) => {
  await worker.trigger({
    function_id: "database::execute",
    payload: { db: DB, sql: "DELETE FROM links WHERE code = ?", params: [payload.code] },
  });
  await worker.trigger({
    function_id: "state::delete",
    payload: { scope: "links", key: payload.code },
  });
  logger.info("link deleted", { code: payload.code });
  return { deleted: true };
});
```

Now add a wrapper that asks the connected browser first, then deletes only if the browser confirms.
The server-side `worker.trigger` of a browser-registered function is the same primitive you've used
between server workers, in reverse. This lets us reverse the typical pattern: instead of the browser asking the server for
confirmation, the server asks the browser.
Other implementations would achieve this with SSE but here it works like normal synchronous
code.

```typescript link/src/index.ts
worker.registerFunction("link::request_delete", async (payload: { code: string }) => {
  const { confirmed } = await worker.trigger<
    { code: string; action: string },
    { confirmed: boolean }
  >({
    function_id: "user::confirm_destructive_op",
    payload: { code: payload.code, action: `delete link "${payload.code}"` },
  });
  if (!confirmed) {
    return { deleted: false };
  }
  await worker.trigger({ function_id: "link::delete", payload: { code: payload.code } });
  return { deleted: true };
});
```

## Scaffold the frontend

### Initialize a Vite project

Create a Vite + React + TypeScript app under `linkly/frontend/`:

```bash
npm create vite@latest frontend -- --template react-ts
```

<Note>
  Vite may ask you to "Install with npm and start now", answer no here as we first need to install
  `iii-browser-sdk`
</Note>

Now install the dependencies:

```bash
cd frontend
npm install
npm install iii-browser-sdk
```

### Setup a client-side worker

Connect the SDK to our iii instance in `frontend/src/iii.ts`. Note how we're using a different URL
format and port, this goes back to the setup we did with `iii-worker-manager` and RBAC.

```typescript src/iii.ts
import { registerWorker } from "iii-browser-sdk";

const TOKEN = import.meta.env.VITE_LINKLY_TOKEN ?? "dev-token";

export const worker = registerWorker(`ws://localhost:3110?token=${encodeURIComponent(TOKEN)}`);
```

### Create the application

We'll build `src/App.tsx` in pieces. You can replace the template's `src/App.tsx` with the code
samples below.

#### Add imports

First the imports and types: `Click` is one row from the `clicks` table, and `StreamEvent` is the
wrapper `iii-stream` delivers to subscribers.

```tsx src/App.tsx
import { useEffect, useState } from "react";
import { worker } from "./iii.js";

type Click = { code: string; clicked_at: string };
type StreamEvent = {
  event: { type: "create" | "update" | "delete"; data: Click };
};
```

#### Add client-side state

Open the component and declare its state: the form fields, the newly created link, and the live
click counter.

```tsx src/App.tsx
export default function App() {
  const [url, setUrl] = useState('')
  const [code, setCode] = useState('')
  const [created, setCreated] = useState<{ code: string; url: string } | null>(null)
  const [clicks, setClicks] = useState(0)
  const [latest, setLatest] = useState<Click | null>(null)
```

#### Subscribe to `clicks`

Subscribe to the `clicks` stream we setup in Chapter 5. The `useEffect` registers a function the
browser exposes (`ui::on_click`) and a `stream` trigger that routes every new row to it; the cleanup
unregisters both on unmount:

```tsx src/App.tsx
useEffect(() => {
  const fn = worker.registerFunction("ui::on_click", async (event: StreamEvent) => {
    setClicks((n) => n + 1);
    setLatest(event.event.data);
    return null;
  });
  const trig = worker.registerTrigger({
    type: "stream",
    function_id: "ui::on_click",
    config: { stream_name: "clicks", group_id: "all" },
  });
  return () => {
    trig.unregister();
    fn.unregister();
  };
}, []);
```

#### Create a function

Register the function the server calls back when it needs human confirmation. It shows a native
prompt and returns the user's decision:

<Note>
  This function registers and runs the exact same as other functions did in previous chapters.
  Except for managing auth and permissions there is no functional difference between client side and
  server side.
</Note>

```tsx src/App.tsx
useEffect(() => {
  const fn = worker.registerFunction(
    "user::confirm_destructive_op",
    async (data: { action: string; code: string }) => {
      const confirmed = window.confirm(`Confirm: ${data.action}?`);
      return { confirmed };
    },
  );
  return () => fn.unregister();
}, []);
```

#### Create links directly, no gateways

Submit the form by calling `link::create` directly.

<Note>
  There is no `fetch` or REST API in the way here, the client worker in the browser works the exact
  same as every other worker.
</Note>

```tsx src/App.tsx
async function onSubmit(e: React.FormEvent) {
  e.preventDefault();
  const link = await worker.trigger<{ url: string; code?: string }, { code: string; url: string }>({
    function_id: "link::create",
    payload: { url, code: code || undefined },
  });
  setCreated(link);
  setUrl("");
  setCode("");
}
```

#### Create the UI

Finally, the UI: a link shortener form, the last-created link, and the live streaming click counter.

```tsx src/App.tsx
  return (
    <main>
      <h1>Linkly</h1>

      <form onSubmit={onSubmit}>
        <label>URL <input value={url} onChange={(e) => setUrl(e.target.value)} required /></label>
        <label>Code (optional) <input value={code} onChange={(e) => setCode(e.target.value)} /></label>
        <button type="submit">Shorten</button>
      </form>

      {created && (
        <p>
          Created <code>{created.code}</code> → <code>{created.url}</code>.
        </p>
      )}

      <section>
        <h2>Live clicks: {clicks}</h2>
        {latest && (
          <p>Last: <code>{latest.code}</code> at <code>{latest.clicked_at}</code></p>
        )}
      </section>
    </main>
  )
}
```

## See it work

Start the UI:

```bash
npm run dev
```

Open your browser, Vite typically hosts local websites at
[http://localhost:5173](http://localhost:5173).

### Shorten a link, then see the visits streamed in realtime

Shorten a link from the form, then visit `http://localhost:3111/s/<code>` a few times. You'll see
the "Live clicks" counter goes up in real time.

### Request user confirmation directly from the backend

Then run a function in the browser via iii by runnning:

```bash
iii trigger link::request_delete code=<code>
```

The browser will show a confirm prompt, and the server deletes only after you click OK.

## Conclusion

The client is a worker that is exactly the same as every other worker. We connected it through an
RBAC-gated listener (via `iii-worker-manager`) that uses an auth function to admit it because the
browser isn't trusted like our other workers. However any other worker can be gated this same way.

Once everything is set up our client calls server functions directly, subscribes to streams for live
updates, and registers functions the server calls back, all on the same iii bus as the rest of
Linkly.

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 →