build-an-agent-skill · git:20260920.ad744e2 · 2026-09-20 · sha256 0a1e49c876aacb76
build-an-agent-skill git:20260920.ad744e2A
Immutable. This exact content is served forever at /api/v1/blob/0a1e49c876aacb76.
---
name: build-an-agent-skill
description: Write a real three.ws agent skill bundle (manifest.json + SKILL.md + tools.json + handlers.js) that gives a 3D agent a new capability, then install it on an agent and test it. Use when you or the user want to build, write, author, or code a custom skill, capability, tool, or ability for a three.ws agent or an <agent-3d> embed ("give my agent a new skill", "make my agent able to check the weather", "write a custom tool for my avatar", "add a gesture skill"). Covers the four-file bundle layout, the handler context API, the sandbox limits, trust policy, and how to install the bundle.
when_to_use: The user wants an agent to be able to DO something new. To then charge for it, use sell-an-agent-skill. To create the agent itself first, use create-a-three-ws-agent. This is not about Claude skills: it is the three.ws in-app skill format that runs inside the agent runtime.
license: MIT
metadata:
category: platform/agents
cross-platform-safe: false
pack: three-ws-skills
---
# Build a three.ws agent skill
A three.ws skill is a directory served from any URL. The agent runtime fetches it,
injects its instructions into the system prompt, exposes its tools to the LLM, and runs
its handlers in a Web Worker sandbox. Because a skill is just files behind a URL, the
same bundle installs into every agent without copying code.
Do not confuse this with the Claude skill you are reading: that one teaches an external
model how to use three.ws. This one teaches a three.ws agent a new capability.
## The four files
| File | Required | Purpose |
| --- | --- | --- |
| `manifest.json` | yes | Identity, version, what it provides, config defaults |
| `SKILL.md` | yes | Instructions injected into the agent's system prompt |
| `tools.json` | yes, if it exposes tools | JSON Schema tool definitions the LLM can call |
| `handlers.js` | no | ES module implementing each tool |
A bundle with no `handlers.js` is valid and always trusted: it is a declarative skill
that only shapes behavior (an accent, a house style, a refusal policy).
Assets (`clips/`, `morphs/`, `prompts/`, `assets/`) sit beside those files and are
resolved against `ctx.skillBaseURI`.
## A complete, working bundle
Copy this shape. The repo's starter is [`examples/skills/wave/`](https://github.com/nirholas/three.ws/tree/main/examples/skills/wave)
and the runtime contract is [docs/skills.md](https://three.ws/docs/skills).
`manifest.json`
```json
{
"spec": "skill/0.1",
"name": "weather-report",
"version": "0.1.0",
"description": "Look up the current temperature and wind for any named place and say it out loud.",
"license": "MIT",
"tags": ["weather", "voice"],
"requires": { "runtime": ">=0.1.0", "capabilities": [] },
"provides": { "tools": ["reportWeather"], "triggers": [] },
"config": { "units": "metric" }
}
```
`tools.json`
```json
{
"tools": [
{
"name": "reportWeather",
"description": "Report the current temperature and wind speed for a named place, and speak the result.",
"input_schema": {
"type": "object",
"properties": {
"place": { "type": "string", "description": "City, town, or landmark name" }
},
"required": ["place"]
}
}
]
}
```
`handlers.js`
```js
// Two chained ctx.fetch calls against Open-Meteo: free, no API key, CORS open.
export async function reportWeather(args, ctx) {
const place = String(args?.place || '').trim();
if (!place) return { ok: false, error: 'place is required' };
const geo = await ctx.fetch(
`https://geocoding-api.open-meteo.com/v1/search?count=1&name=${encodeURIComponent(place)}`,
);
if (!geo.ok) return { ok: false, error: `geocoding failed (${geo.status})` };
const hit = (await geo.json())?.results?.[0];
if (!hit) return { ok: false, error: `no place called "${place}"` };
const imperial = ctx.skillConfig?.units === 'imperial';
const query = new URLSearchParams({
latitude: String(hit.latitude),
longitude: String(hit.longitude),
current: 'temperature_2m,wind_speed_10m',
...(imperial ? { temperature_unit: 'fahrenheit', wind_speed_unit: 'mph' } : {}),
});
const res = await ctx.fetch(`https://api.open-meteo.com/v1/forecast?${query}`);
if (!res.ok) return { ok: false, error: `forecast failed (${res.status})` };
const { current, current_units: u } = await res.json();
const line =
`It is ${current.temperature_2m}${u.temperature_2m} in ${hit.name}, ` +
`wind ${current.wind_speed_10m} ${u.wind_speed_10m}.`;
await ctx.speak(line);
ctx.memory.note('weather-report', { place: hit.name, ...current });
return { ok: true, data: { place: hit.name, ...current }, sentiment: 0.2 };
}
```
`SKILL.md` (the agent-facing instructions, not this file)
```markdown
# Weather report
When the user asks about the weather, temperature, or wind at a named place, call
`reportWeather` with that place name. Never guess the numbers: if the tool returns an
error, say you could not reach the weather service.
```
One exported function per tool name in `provides.tools`, matching `tools.json`. Return
`{ ok: true, ... }` on success and `{ ok: false, error: 'message' }` on failure; the
runtime surfaces the error to the LLM and fires a `skill-error` event instead of
crashing the agent. A numeric `sentiment` between -1 and 1 drives the avatar's empathy
layer (positive blends a celebration, negative blends concern).
## The handler context
`ctx` is the only way a handler reaches the outside world:
| Group | Calls |
| --- | --- |
| Scene | `ctx.viewer.play(clip, { blend })`, `stop`, `setExpression`, `lookAt`, `moveTo`, `playAnimationByHint(hint, { duration_ms })` |
| LLM | `ctx.llm.complete(prompt, opts)`, `ctx.llm.embed(text)` |
| Memory | `ctx.memory.read/write/note`, `ctx.memory.recall(query)` (substring search, not embeddings) |
| Assets | `ctx.loadClip(uri)`, `ctx.loadGLB(uri)`, `ctx.loadJSON(uri)`, `ctx.skillBaseURI` |
| Network | `ctx.fetch(uri, opts)` (normal CORS rules apply) |
| Other skills | `ctx.call(toolName, args)`, which crosses skill boundaries into built-ins like `speak` |
| User | `ctx.speak(text)`, `ctx.listen(opts)` |
`ctx.skillBaseURI` always ends in `/`, so resolve bundled assets with
`new URL('./clips/wave.glb', ctx.skillBaseURI).href`. Every individual `ctx.*` call
times out after 30 seconds.
## Sandbox limits that change how you write the handler
Handlers run in a Web Worker with no DOM. They **cannot** touch `document`, `window`,
`navigator`, `location`, cookies or storage, **cannot** use `import` statements inside
`handlers.js` (the blob module has no base URL for relative imports), and **cannot**
make network calls outside `ctx.fetch` / `ctx.loadJSON`.
Everything a skill actually needs stays available through `ctx.*`. Write one
self-contained module, no bundler, no dependencies.
A skill that genuinely needs main-thread Three.js or per-frame work sets
`"sandboxPolicy": "trusted-main-thread"` in its manifest, and that opt-out is honored
only for skills that pass the agent's `owned-only` or `whitelist` trust check. Skills
loaded under `any` trust stay sandboxed no matter what the manifest says.
## Trust
The registry enforces a per-agent trust policy: `owned-only` (default, `manifest.author`
must match the owner's wallet), `whitelist` (a list of publisher wallets), or `any`
(kiosks and demos). Under `owned-only`, a mismatched `author` makes `install()` throw
before any handler code is fetched. When the manifest carries `integrity` hashes, they
are verified before execution.
Set `author` to the owner's wallet address when the skill is meant to run on that
owner's agents.
## Install it
Host the directory anywhere that serves the files over HTTPS (or pin it to IPFS or
Arweave: `ipfs://` and `ar://` URIs resolve through a gateway chain). Then reference it.
From the agent manifest, with a pinned version range:
```json
{
"skills": [{ "uri": "https://your-site.com/skills/weather-report/", "version": "^0.1.0" }]
}
```
Or from the embed, comma separated (the attribute cannot pin versions):
```html
<agent-3d
agent-id="<your agent id>"
skills="https://your-site.com/skills/weather-report/"
></agent-3d>
```
A skill can depend on other skills through `dependencies` (a map of skill URI to version
range). The registry installs them recursively first, detects circular dependencies, and
never re-fetches an already-installed URI.
## Test it for real
1. Serve the bundle locally (`npx -y serve .` or any static server) and embed the agent
on a page with `skills="http://localhost:3000/skills/weather-report/"`.
2. Listen to the element events and watch the round trip:
```js
const el = document.querySelector('agent-3d');
el.addEventListener('skill:tool-start', (e) => console.log('start', e.detail));
el.addEventListener('skill:tool-called', (e) => console.log('called', e.detail));
```
3. Or bypass the LLM and call the tool directly in the console of the three.ws app:
```js
await window.VIEWER.agent_skills.perform('reportWeather', { place: 'Bondi Beach' });
```
4. Confirm all three lifecycle events fire on the protocol bus: `perform-skill`,
then `skill-done` (or `skill-error` with the message you returned).
Verify the failure path too. A handler whose upstream is down must return
`{ ok: false, error }`, not throw an unhandled rejection.
## Next
- **Charge for it**: `sell-an-agent-skill` publishes the skill to the marketplace and
prices it per call in $THREE, with the author's share routed to their own wallet.
- **Reference**: [docs/skills.md](https://three.ws/docs/skills) for the full runtime
contract, [three.ws/tutorials/custom-skill](https://three.ws/tutorials/custom-skill)
for a guided build, and `examples/skills/` in the repo for working bundles.