Build a three.ws agent skill

SkillAI & models

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.

Available today. Use it from your connected AI after setup.

Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.

Then ask your AI: use the Build a three.ws agent skill skill

What this skill tells your AI

The instructions your AI receives, as published by nirholas/three.ws in .agents/skills/build-an-agent-skill/SKILL.md and read by ahel’s review.

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

FileRequiredPurpose
manifest.jsonyesIdentity, version, what it provides, config defaults
SKILL.mdyesInstructions injected into the agent's system prompt
tools.jsonyes, if it exposes toolsJSON Schema tool definitions the LLM can call
handlers.jsnoES 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/ and the runtime contract is docs/skills.md.

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

{
  "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

// 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)

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

GroupCalls
Scenectx.viewer.play(clip, { blend }), stop, setExpression, lookAt, moveTo, playAnimationByHint(hint, { duration_ms })
LLMctx.llm.complete(prompt, opts), ctx.llm.embed(text)
Memoryctx.memory.read/write/note, ctx.memory.recall(query) (substring search, not embeddings)
Assetsctx.loadClip(uri), ctx.loadGLB(uri), ctx.loadJSON(uri), ctx.skillBaseURI
Networkctx.fetch(uri, opts) (normal CORS rules apply)
Other skillsctx.call(toolName, args), which crosses skill boundaries into built-ins like speak
Userctx.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:

{
  "skills": [{ "uri": "https://your-site.com/skills/weather-report/", "version": "^0.1.0" }]
}

Or from the embed, comma separated (the attribute cannot pin versions):

<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:
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));
  1. Or bypass the LLM and call the tool directly in the console of the three.ws app:
await window.VIEWER.agent_skills.perform('reportWeather', { place: 'Bondi Beach' });
  1. 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 for the full runtime contract, three.ws/tutorials/custom-skill for a guided build, and examples/skills/ in the repo for working bundles.

Signals

GitHub stars
217
Forks
52
Last commit
Sep 2026
Advanced
Item type
skill
Key
build-an-agent-skill
Source
github.com/nirholas/three.ws