Skill authoring guide — single-file .xgs.js standard
Canonical reference for the xgoose skill format. One file = one skill: a Tampermonkey-style // ==XGooseSkill== … // ==/XGooseSkill== header at the top, ES-module body underneath, distributed as <name>.xgs.js. Drop the file into any host extension (xgoose-extension-chrome, xgoose-extension-safari, …); the runtime parses the header, gates on @match + @detect, bundles the body, and assigns the module's default export onto window.xgoose.skills.<namespace>.
The .xgs.js extension is what each host extension watches for in the address bar: navigating to a raw skill URL pops the extension's install page automatically (the older .user.js extension collided with Tampermonkey / Violentmonkey, which hijacks any *.user.js navigation regardless of the header body).
The format is intentionally shareable — any .xgs.js you can point a browser at can become an installable skill, and the agent itself can edit the file at runtime to add a new @match host.
This doc is the source of truth shipped with @xgoose/skill-runtime. Both host repos copy it into their skills/ directory; the website mirrors it under /docs/skills. If you change one, sync the others.
TL;DR
// ==XGooseSkill==
// @name discourse-forum
// @namespace discourse
// @version 0.2.0
// @description Helpers for Discourse-powered forums (search, read, post).
// @match *://meta.discourse.org/*
// @match *://*.discourse.org/*
// @match *://www.uscardforum.com/*
// @match *://linux.do/*
// @detect !!document.querySelector('meta[name="generator"][content^="Discourse"]')
// @primary searchTopics
// @keyword discourse
// @keyword forum
// @keyword topic
// ==/XGooseSkill==
import { defineSkill, fn } from "@xgoose/skill";
import { z } from "zod";
export default defineSkill({
searchTopics: fn({
summary: "Full-text search across topics. ★ primary entry.",
input: z.object({
query: z.string().describe('Search query, e.g. "amex platinum 175k"'),
page: z.number().int().min(0).optional(),
order: z
.enum(["relevance", "latest", "views", "likes", "activity", "posts"])
.optional()
.describe('Default "relevance". Use "latest" for time-ordered.'),
}),
examples: [
{ args: { query: "chase sapphire reserve" } },
{ args: { query: "IHG ghost reservation", order: "latest" } },
],
handler: async ({ query, page, order }) => {
const params = new URLSearchParams({ q: query });
if (page !== undefined) params.set("page", String(page));
if (order) params.set("order", order);
const r = await fetch(`/search.json?${params}`, {
credentials: "include",
});
if (!r.ok) throw new Error(`search → HTTP ${r.status}`);
return await r.json();
},
}),
// …other functions
});
The // ==XGooseSkill== … // ==/XGooseSkill== block is the only metadata; everything else is a normal ESM module. Each exported function in defineSkill({ … }) becomes window.xgoose.skills.<namespace>.<name> and the agent invokes it via js_exec (await xgoose.skills.discourse.searchTopics({ query: "…" })). The Zod schema is documentation: it feeds the per-turn system reminder (every active skill's full input/output JSON Schema is inlined there, so the agent doesn't need a help() round trip) but is NOT enforced at runtime — bad args land in your handler and the destructure throws naturally.
Skills are dynamically loaded. create_skill and edit_skill mid-conversation produce a fresh IIFE in the service worker and hot-inject it into the current tab; the agent's very next js_exec runs the new code. No page reload required, no republish through xgoose-web required for local use — see § Dynamic loading below.
File layout
Built-in skills live alongside the host source:
xgoose-extension-chrome/skills/
xgoose-extension-safari/skills/
├── discourse-forum.xgs.js
├── flyertalk.xgs.js
├── …
└── SKILL_AUTHORING.md ← this doc
User-installed and agent-edited skills are persisted to chrome.storage.local under xgoose.skills.user. They have the same on-disk shape; the host extension's options page is the human-facing editor.
The build pipeline (scripts/build-skills.mjs):
- Walks
skills/*.xgs.js. - Parses the
==XGooseSkill==header via@xgoose/skill-runtime'sparseSkillFile→ validated againstSkillHeaderSchema. - esbuild-bundles the JS body as an IIFE that:
- evaluates
@detectin the page MAIN world - if truthy, instantiates the module, and assigns its
defaultontowindow.xgoose.skills[<namespace>]
- evaluates
- Calls
__describe__()on the bundled output to extract per-function summaries (used bylist_skillsand the system reminder). - Emits
Resources/skills/<name>.xgs.js(header preserved + bundled body) and a slimregistry.jsonfor the host to load on install.
Header directives
All directives are // @key value lines between the two markers. Repeatable keys collect into arrays; everything else is a scalar. Unknown directives are preserved on extra and round-trip through publish without being interpreted.
| Directive | Repeatable | Required | Notes |
|---|---|---|---|
@name | – | yes | Globally unique, kebab-case (a-z0-9-). Identity for storage & registry. |
@namespace | – | yes | JS identifier ([A-Za-z_$][\w$]*). Becomes xgoose.skills.<namespace>. Pick a short, unique one. |
@version | – | – | Semver. xgoose-web auto-bumps the patch on republish. Defaults to 0.0.0. |
@description | – | – | One-line summary, shown verbatim to the agent in list_skills. |
@match | yes | – | Chrome match pattern. Skill activates when location.href matches any. |
@include | yes | – | Simple glob (* / ?). Looser syntax than @match. |
@exclude | yes | – | Subtracts from the match set. |
@detect | – | – | Single-line JS expression evaluated in MAIN world. Must be truthy for the skill to mount. Falsy / thrown = skip. |
@run-at | – | – | document_start / document_end / document_idle (default). |
@all-frames | – | – | true to also inject into iframes. Default false. |
@require | yes | – | Other skill names this depends on. Loaded first (topological sort). |
@connect | yes | – | Declared cross-origin hosts. "<self>" = same-origin only. Currently informational. |
@primary | – | – | Name of the ★ primary exported function. List_skills surfaces its signature; other functions are name-only until the agent calls help. |
@keyword | yes | – | Surfaced by search_skills({ query }) even when the skill isn't active on the current page. Include site nicknames + slang. |
@homepage | – | – | Author docs URL. |
@author | – | – | Author display name. |
Things that will silently waste your day
@matchdoesn't match the URL → skill never activates. Verify inlist_skillswithscope: 'page'.@detectthrows → treated as falsy → skill never activates. Wrap risky property reads (?.everywhere;try { … } catch { return false }if you must).@namespacecollides with another skill → the second install wins. Choose a unique short namespace.- Forgetting
@primary→ agents only see name + summaries; they always pay ahelpround-trip before their first call. @detectreferencing DOM that isn't ready → set@run-at document_idle(the default) or have the detect checkdocument.readyState.
defineSkill and fn
import { defineSkill, fn } from '@xgoose/skill';
import { z } from 'zod';
export default defineSkill({
fnName: fn({
summary: string; // shown to the agent in list_skills + help + the per-turn system reminder
input: ZodSchema; // documentation-only: rendered as JSON Schema inline in the system reminder. NOT validated at runtime.
output?: ZodSchema; // documentation-only: rendered as JSON Schema inline in the system reminder.
examples?: Array<{ args; note?; returns? }>; // inlined verbatim into the system reminder for every active skill
handler: async (args) => { … }; // receives the raw args object the caller passed
}),
// …
});
What defineSkill returns:
- Each function name on the result is the handler. Args are passed through as-is — the runtime does NOT validate. If the agent hands you a bad shape, your destructure throws and the error reaches the agent's
js_execresult, which it interprets and self-corrects (usually after onehelpround-trip). __describe__(fnName?)is the CLI-style help payload. With no argument: a{ [fnName]: { summary, signature, invocation, primary? } }map. With a name: the full{ name, summary, invocation, input (JSON Schema), output? (JSON Schema), examples }envelope.__meta__carries{ name, namespace, primary, functions[] }for callers that need to introspect without re-parsing the header.
The __isXgooseSkill__: true marker lets the runtime distinguish a real skill bundle from a hand-rolled object.
The invocation field in __describe__ is a copy-pasteable js_exec snippet (e.g. await xgoose.skills.discourse.searchTopics({...})); the per-turn system reminder surfaces it next to each function's signature so the agent doesn't have to re-derive xgoose.skills.<ns>.<fn> from the namespace + name on every call.
As of the schemas-in-context change, __describe__() (the no-arg form) now also returns each function's full input/output JSON Schema and authored examples. build-skills.mjs ships all of that into registry.json, and the extension's per-turn system reminder inlines the schema for every active skill — so for any skill that's loaded on the current page, the agent has the entire call shape (no help() call needed). help() remains useful only for skills that are installed but not active on the current page (those don't appear in the reminder).
How the agent invokes your skill
Skills are exposed as plain in-page helpers on window.xgoose.skills.<namespace>.<fnName>. The agent invokes them by writing a js_exec snippet — there is no dedicated skill_call tool. A typical call looks like:
return await xgoose.skills.discourse.searchTopics({ query: "amex platinum 175k", order: "latest" });
Two consequences fall out of this design:
- The runtime does not validate args. The
input/outputZod schemas you pass tofn(...)exist only as documentation — they become JSON Schema that the per-turn system reminder inlines next to each function for every active skill. If the agent passes a bad shape, the handler's destructure (or first property access) throws naturally; that error bubbles back tojs_execand the agent self-corrects after re-reading the reminder's schema. This matches xgoose's fail-fast philosophy. - The agent can compose freely. Because every call is just JS, the agent can chain three skill calls in one
Promise.all, splice in a rawfetchfor something the skill doesn't cover, or transform args/results inline — all in one tool call. Quick-recipe blocks in your handler JSDoc / file-top comments are valuable because the agent often copies them verbatim.
The agent-facing CLI surface
The host extension exposes seven registry tools on top of the skill set. Optimize your skill for these tools, not for free-text docs — that's how agents pay attention. Invocation is js_exec, not a tool.
| Tool | What it returns | When the agent reaches for it |
|---|---|---|
list_skills({ scope?: 'all' | 'page' }) | Compact rows: name, namespace, description, matches, keywords, enabled, activeOnThisPage, primary function (signature + invocation snippet + summary), names of other functions. | When the agent wants to discover what's installed but not active. (Active skills are already inlined in the per-turn system reminder with full schemas — no list_skills call needed for those.) |
search_skills({ query, limit? }) | Same compact rows, scored, ranked by relevance. | When the user asks for something the active skills don't cover ("any forum skill?", "anything for hotels?"). |
help({ skill, function? }) | Full input/output JSON Schema, ready-to-paste invocation snippet, and verbatim examples. Falls back to compact static summaries when the skill isn't loaded on the current page. | Only needed for skills not active on the current page (active ones are inlined in the reminder). Also useful when an active user-skill couldn't be statically introspected and the reminder marks its schema as "unavailable inline". |
read_skill({ name }) | Raw .xgs.js source. | When the agent intends to edit_skill (needs the full text to modify it). |
edit_skill({ name, source }) | { name, namespace, version, bodyChanged, activeOnThisPage, hint }. Hot-reloads the new IIFE into the current tab immediately if the skill matches this URL — the agent's next js_exec runs the new code. Other open matching tabs hot-reload async via the storage watcher. | When the agent wants to add a @match for a new host, bump a version, or rewrite a handler. Built-in skills clone to a user override on first edit. |
create_skill({ source }) | { name, namespace, version, activeOnThisPage, hint }. Header is parsed client-side; fails if the name already exists (use edit_skill for that). Hot-loaded into the current tab if @match covers it. | When the agent has a brand-new .xgs.js it wants to register. Pair with skill_help() first. |
skill_help() | This document, returned verbatim as a string. | Before writing a new skill or substantially editing one. |
The single biggest mental shift from old-style skills is that per-turn context carries the full input/output JSON Schema and examples for every function of every active skill — no help() round trip needed. The trade-off: every byte you spend on .describe(), schema refinement, and examples ends up in the system reminder. For a typical 5–10-function active skill that's ~3–8 KB per turn (≈ 0.7–2 K tokens), and the reminder is in the prompt cache so the steady-state cost is near-zero. Be deliberate but not stingy — the schema is what lets the agent get the call right on the first try.
So the highest-ROI parts of your skill file are:
- A short
@description(one sentence). - An obvious
@primary(the ★ entry) — the system reminder lists it first for each skill. - Each
fn'ssummary(one line) + tight Zod schema with.describe()on non-obvious fields — these go inline into the system reminder for every active skill. - One or two
examplesper function — also inlined into the reminder verbatim. The agent usually copies these as the args object inside itsjs_execsnippet.
Long-form caveats, failure-mode notes, and quick-recipe js_exec bodies live in JS code comments at the top of the file. The agent reads them via read_skill when it wants to edit. They do not end up in the per-turn reminder.
The eight rules that decide whether the agent succeeds first try
Distilled from the bake-off across the built-in skills (alaska-awards, aa-awards, hyatt-awards, discourse-forum, vbulletin-forum, flyertalk, uscardforum, uscreditcardguide):
- Declare a single ★
@primary. One clear entrypoint the agent reaches for first. Mark it in the header. Use other entrypoints for lower-level access. The system reminder lists this function first for the skill, marked with ★. - Every function takes one options object. No positional args, no overloads. The agent will copy your shape exactly.
z.object({…})always; neverz.tuple([…])at the top level. (The schemas are pure documentation — the runtime doesn't enforce them — but the agent reads them as the source of truth.) - No
z.any()in inputs unless you mean it. If a field is a URL, usez.string().url(). If it's a date,z.string().regex(/^\d{4}-\d{2}-\d{2}$/). Every loose schema is a guess the agent has to make. - Declare concrete return shapes via
output(or in the functionsummaryif you can't). "Returns a list of search results" is useless; "Returns{ topic_list: { topics: TopicListItem[] } }whereTopicListItem.idis the topic id passed togetTopic" is gold. - Use
.describe()on tricky inputs. The system reminder surfaces this verbatim inline for every active skill. The agent reads it like CLI flag help — it's the only signal there is, since args aren't validated at runtime. - Provide ≥ 1 example per function. Real, copy-pasteable args (and a
returnshint if it's surprising). The agent literally copies these inside itsjs_execsnippet. - Throw on real errors; return shaped status on expected ones. "Akamai blocked", "sold_out", "no results" are values the caller will branch on. "Property code is empty" is an exception. Don't swallow errors with bare
try {…} catch {}— let real errors bubble all the way back throughjs_exec. - Date format is
YYYY-MM-DD. Currency is the property's local currency unless you explicitly normalize. Stick to ISO; document the currency field; never silently mix units.
Runtime conventions
- Same-origin first. The skill runs in the page MAIN world.
fetch(url, { credentials: 'include' })carries the user's cookies and CSRF tokens. Prefer that over CORS dances. - Use
URLSearchParamsfor query strings. Saves encoding bugs. - Concurrency control belongs in the skill. If you fan out per-night fetches, throttle with a small in-skill semaphore. The hotel skills default to 4–6 in flight.
- No
chrome.*fromskill.ts. It runs in MAIN world;chromeisn't defined. If you need extension APIs, that's a host tool, not a skill. - No
eval/new Function/ stringsetTimeout. Most modern sites disallowunsafe-evaland there's no legitimate reason a skill needs runtime code generation. - No secrets in source. The bundled JS ships to every user's machine and is visible in DevTools.
- Don't patch globals (
fetch,console,window). The page bridge already patches them; double-patching collides. - Zod is documentation, not validation. The runtime hands your handler the raw args object the agent passed via
js_exec. If you need to coerce or default a value, do it inside the handler. If the shape is wrong, let your destructure or property access throw — the error reaches the agent, which re-readshelpand self-corrects. Do not write defensiveif (!args.foo) return …paths; do throw clear errors.
Skill injection (loading your code into MAIN world while satisfying page CSP) is a runtime concern, not a skill concern. The host extension picks the right injection strategy for the browser. Write your skill as if every host had a friendly CSP; the runtime deals with the hostile ones.
Cross-skill dependencies
Declare in the header:
// @require vbulletin-forum
The injector topologically sorts requires, so by the time your skill mounts, window.xgoose.skills.vbulletin.* is populated. Don't import other skills — they live in separate ESM realms; reference them through the page global:
const v = window.xgoose?.skills?.vbulletin;
if (!v)
throw new Error("flyertalk.searchAll: vbulletin-forum skill not loaded");
Cross-origin and @connect
If your skill fetches a host that isn't the page's origin, declare it:
// @connect <self>
// @connect apis.ihg.com
CORS handles the actual permission today; @connect is currently informational. The injector may start enforcing an allowlist later, so declare honestly.
Reverse-engineering checklist (new vendor skill)
The playbook that produced the hotel + airline pilots:
- Use the site UI as your spec. Open the property/topic/award calendar page in a logged-in session, do the action, record DevTools Network.
- Identify the smallest URL + body that reproduces the data. Strip query params and headers one at a time until you have the minimum that still 200s.
- Note the auth model. Anonymous? Cookie-based? CSRF token from a meta tag? Header-based bearer? Are some fields gated behind login?
- Document the captured shape inline. Put the request body + observed response keys in code comments near the function that uses them so the next maintainer (or the agent doing
edit_skill) doesn't have to re-capture from scratch. - Document failure modes you observed. 403 patterns, rate limits, sensor cookies, persisted-query signatures. The agent's user benefits more from "the tab needs a refresh" than from a generic error.
- Write the skill with one ★
@primarythat takes a date range / query, fans out internally, and returns the flattened summary the user wants. Raw entrypoints are fine but secondary.
Publishing
Once installed, click Publish to xgoose-web on the skill chip in the panel (or use the upload form at xgoose.org/publish/). The host POSTs the canonical .xgs.js source to POST /skills with your bearer token. If you republish the same name with a non-greater @version, the registry auto-bumps the patch (e.g. 0.2.0 → 0.2.1) and the response tells you the assigned version. Subsequent installs always pull a .xgs.js whose @version line matches the registry.
The publisher (UI under xgoose-web/web/components/SkillUploader.tsx) parses the same header schema this doc describes, bundles your body via esbuild-wasm right in the browser, runs __describe__() on the bundle to extract per-function summaries, and ships those alongside the raw source. The marketplace card on xgoose.org/skill?slug=… shows the resulting function list with ★ markers.
Dynamic loading — create_skill and edit_skill are hot
The host extension's skill registry is fully dynamic. The agent's create_skill / edit_skill tools don't require a page reload or a republish through xgoose-web for the new bundle to become invocable on the current page. The flow:
- Agent calls
create_skill({ source })(orread_skill→ modify →edit_skill). - Service worker parses the header, calls
bundleUserSkill(body, header)to produce the install-ready IIFE, and writes it tochrome.storage.local(xgoose.skills.user).UserSkill.updatedAtis stamped toDate.now(). - The tool, before returning to the agent, calls
sw:active_skills_for_urlfor the current tab's URL, invalidates the skill's entry in the content-script-side injection cache, and re-runsinjectSkills([thatSkill]). The bundle's IIFE re-executes; its tail doesObject.assign(window.xgoose.skills[ns], __skill__), so the callable functions replace cleanly without a page reload. - The agent's very next
js_execsnippet can invokeawait xgoose.skills.<ns>.<fn>(args)against the new code. - Asynchronously, the SW's storage watcher fires and pushes
cs:set_active_skillsto every other open tab. Tabs whose URLs match see a fingerprint change (u:<updatedAt>) and re-inject too; tabs that already saw the proactive injection no-op because the fingerprint already matches.
If the new skill's @match patterns don't cover the current URL, the response carries activeOnThisPage: false and a hint to navigate. The skill is still installed and will hot-load the moment the user lands on a matching page.
Built-in skills get cloned to a user-skill copy on first edit, so the original ships value is preserved. Subsequent edits update the override in place. This is the canonical way to make the discourse / vBulletin / iCloud-style "one skill, many sites" pattern actually scale — rather than hard-coding N hosts, the skill grows when the user (or the agent) lands on a new site, and the new @match host takes effect immediately.
Publishing via xgoose-web is independent of local use. Republishing is only required when you want to share the skill with other users (the marketplace pulls the canonical source). For your own session, create_skill / edit_skill are enough.
Quick checklist before committing a skill
-
// ==XGooseSkill== / ==/XGooseSkill==block present with valid@name,@namespace, at least one@match. -
@primarypoints at the ★ entry function. - Every
fn(...)has a Zod input schema (notz.any()), a one-linesummary, and ≥ 1 example. - Tricky inputs use
.describe(...)so the inline reminder schema isn't just{ type: "string" }. - Return shape is documented in
summaryor via anoutputschema (the eight rules, #4). - Failure modes (rate-limits, Akamai, login-gated fields) are noted in
// comments at the top of the bodyfor the agent to read viaread_skill. - No
chrome.*, noeval, nonew Function, no stringsetTimeout. - No secrets in source.
-
npm run build:skillssucceeds and the skill appears inResources/skills/registry.json. - Manual smoke: open a matching URL, open the panel, see the chip, run the primary fn via
js_exec(return await xgoose.skills.<ns>.<fn>({…});) and confirm it returns the documented shape.
If the agent succeeds first try (one js_exec, no help round-trip, no flailing), you wrote a good skill.