Build a w6w App — Agent Instructions
Audience: an LLM / coding agent building a w6w App. This file is written to be pasted into an agent prompt. It is self-contained: the rules, contracts, and examples an agent needs to author a correct App without reading the whole spec. When something here is ambiguous, the RFCs under
../rfcs/and the types in@w6w/typesare the source of truth.
Start from a template — do not scaffold from scratch
Two official starter templates satisfy the identical App contract. Clone one and edit it; the toolchain is the only difference. Prefer Deno unless the user needs Node.
| Template | Toolchain | Use when |
|---|---|---|
| w6w-io/w6w-app-template-deno | Deno + JSR @w6w/types + deno test | Default. Zero-install; matches the reference runtime directly. |
| w6w-io/w6w-app-template-node | Node 22 + npm @w6w/types + tsx + vitest | The user’s stack is Node, or they want vitest. |
Both ship a working app you mutate into the target: bearer-token Auth (sign + test), a read +
a perform Action against httpbin.org, unit tests with a mocked HookContext, and a green CI
workflow. On GitHub, Use this template; or git clone and strip it down.
Keep .ts extensions in imports (import x from "./actions/foo.ts"). The Node template keeps
them too, so the same source runs unchanged under the Deno runtime.
Mental model — the five invariants
Everything below follows from these. Violating one is the most common way an app breaks.
- An App is an npm-style package. Identity lives in
package.json(aw6wblock reusing native fields); behavior lives in an entry module (default./index.ts) thatdefault-exports anAppDefinition—{ actions, auth }. - Code-first, co-located. Each Action and Auth method is one
.tsfile thatdefault-exports an object mixing its config (key, title, params) with its functions (execute,sign,test). No.action.json/.auth.jsonfiles. - Hooks at the boundaries. Anything that varies per publisher — calling the service, injecting
auth, populating a dropdown, validating input — is a hook function the runtime calls. Hooks
receive
(input, ctx). - All network is host-mediated. Untrusted code runs in a network-less Deno sandbox. Reach
the network only via
ctx.fetch; the globalfetchis denied and will throw. Every hostname a hook calls must be declared inw6w.network.allow. - Credentials are opaque. Actions never see raw credentials. The Auth
signhook is the only code handed the credential, and it runs in its own network-less worker — so the credential-holder can’t reach the network and the network-caller has no credential. Neither can leak it.
File layout
my-app/
├── package.json # identity — the `w6w` block (see below)
├── index.ts # entry: default-exports { actions, auth }
├── actions/
│ └── send-email.ts # one file per action, default export = ActionDefinition
├── auth/
│ └── api-key.ts # one file per auth method, default export = AuthDefinition
├── assets/
│ └── icon.svg # referenced from w6w.appearance.icon
└── (deno.json | tsconfig.json) # from the template
package.json — identity
Native npm fields are reused (version, description, author, license, homepage,
repository, keywords). App-specific fields go in the w6w block. Only id, displayName, and
appearance are required there; the rest fall back to native fields or defaults.
{
"name": "@acme/sendgrid",
"version": "1.0.0",
"description": "Send transactional email via SendGrid.",
"license": "MIT",
"author": { "name": "Acme" },
"w6w": {
"id": "io.acme.sendgrid", // reverse-DNS, globally unique, immutable across versions
"displayName": "SendGrid",
"categories": ["communication"], // 1–3 from the controlled vocabulary (rfcs/categories.md)
"appearance": { "icon": { "svg": "./assets/icon.svg" } },
"network": { "allow": ["api.sendgrid.com", "127.0.0.1"] }, // egress allowlist; add 127.0.0.1 for local tests
"entry": "./index.ts" // defaults to package `main`, then ./index.ts
}
}
Rules the validator enforces: id is reverse-DNS (^[a-z0-9-]+(\.[a-z0-9-]+)+$), version is
semver, categories has 1–3 entries. Run deno task validate (the @w6w/validator CLI) to check.
Entry module (index.ts)
import type { AppDefinition } from "@w6w/types";
import sendEmail from "./actions/send-email.ts";
import apiKey from "./auth/api-key.ts";
export default {
actions: [sendEmail],
auth: [apiKey], // optional; omit for a no-auth app
} satisfies AppDefinition;
Writing an Action
An Action is read, search, or perform. Type the input/output for safety.
import type { ActionDefinition } from "@w6w/types";
interface Input {
to: string;
subject: string;
body: string;
apiBase: string;
}
const sendEmail: ActionDefinition<Input> = {
key: "send-email", // kebab-case, unique within the app
type: "perform", // read | search | perform
title: "Send Email",
description: "Send a transactional email.",
idempotent: false, // perform only: is it safe to retry? drives dedupe/retry
params: [
{ key: "to", label: "To", type: "string", required: true },
{ key: "subject", label: "Subject", type: "string", required: true },
{ key: "body", label: "Body", type: "text", required: true },
{
key: "apiBase",
label: "API base URL",
type: "string",
default: "https://api.sendgrid.com",
hint: "Overridable so tests can point at a local endpoint.",
},
],
output: [{ key: "status", type: "number", label: "HTTP status" }],
async execute(input, ctx) {
ctx.log("info", "sending email", { to: input.to });
// Use ctx.fetch — NOT global fetch. No Authorization header here: the runtime
// routes this request through the Auth `sign` hook, which injects the credential.
const res = await ctx.fetch(`${input.apiBase}/v3/mail/send`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({/* ... */}),
});
return { status: res.status };
},
};
export default sendEmail;
requiresAuth defaults to true when the app declares Auth, false otherwise. Set
requiresAuth: false to opt a specific Action out (e.g. a public health check).
Writing an Auth method
test is the only required hook. Add sign to inject credentials into outbound requests. key
is referenced by the stored Connection.
import type { AuthDefinition } from "@w6w/types";
const apiKey: AuthDefinition = {
key: "api-key",
type: "apiKey", // oauth2 | apiKey | basic | bearer | custom | tenantAuth | jit
displayName: "API Key",
apiKey: { in: "header", name: "Authorization", prefix: "Bearer " },
fields: [ // collected at connect time; Param[]
{ key: "apiKey", label: "API Key", type: "secret", required: true },
],
// The ONLY hook given the raw credential. Runs network-less: it stamps auth onto
// the request and returns it, but cannot itself reach the network.
sign({ request, credential }) {
const { apiKey } = credential as { apiKey: string };
request.headers["authorization"] = `Bearer ${apiKey}`;
return request;
},
// Required. Validate the credential is live at connect time.
test() {
return { ok: true };
},
};
export default apiKey;
Full Auth hook lifecycle (all optional except test): preflight → exchange (auth code / form →
opaque credential) → test → afterConnect (fetch label data) → sign (per request) → refresh
(renew expired credential) → revoke (on disconnect). For OAuth2, set type: "oauth2" and the
oauth2 config (authorizationUrl, tokenUrl, scopes, pkce); see
rfcs/auth.md.
Host-sourced credentials (no user fields). Two auth types carry no fields and no connect flow
— the host supplies the credential and hands it to sign as a bearer. Use them for an App that
fronts the tenant/partner’s own API:
tenantAuth— the host mints a live, per-subject token from a per-tenant app link (partner’s token endpoint). Zero per-user setup; works in background runs. SettenantAuth: { link, resourcePrefix? }.jit— the host forwards the caller’s own inbound token (the JWT the tenant minted for this request) verbatim. No app link, nothing to configure; but it only works when the API accepts that token and only for user-driven (not background) runs. Setjit: { resourcePrefix? }.
Both sign hooks just stamp Authorization: Bearer ${credential.token} — the provenance difference
is entirely host-side. An App can declare several auth methods (e.g. jit for the tenant’s base
creds and oauth2 for a separate account); the user picks one per Connection.
HookContext (ctx) — the ambient API
Injected into every hook. This is the entire surface a hook may use for I/O.
| Member | Use |
|---|---|
ctx.fetch | Host-mediated network. The ONLY way to make requests. Same signature as fetch. |
ctx.log(level, msg, data?) | Structured logging. level: "debug" | "info" | "warn" | "error". |
ctx.connection? | The redacted Connection (display metadata only). Never contains the credential. |
ctx.invocation? | Read-only call metadata: invocationId (use as idempotency key for perform), runId, stepId, trigger (skip real side-effects when "editor"/"test"). Data, not authority. |
ctx.host? | Non-portable host capabilities. Reading it ties the app to that host — avoid in portable apps. |
Params — form field types
Every form surface (Action params, Auth fields) is an ordered Param[]. Types: string,
text, number, boolean, select, multiselect, date, datetime, secret, file, json,
code, group, section.
Common fields: key, label, type, required, default, hint, placeholder, secret
(masked + encrypted; implicit for type: "secret"), options (for select/multiselect),
validation (pattern, min/max, minLength/maxLength, integer, enum), dependsOn,
showIf (JSONLogic), children (for type: "group" and type: "section"). Dynamic dropdowns use
options: { source: "./path/to/hook.ts" } returning Option[]. section is a layout-only
container of children — section: "collapsible" for a titled, collapsed-by-default group (e.g.
advanced fields), section: "group" with layout: "row" | "stack" for side-by-side clusters (e.g.
sender email + name). Unlike group (which nests its value under its key), a section’s children
keep their own top-level keys. See rfcs/param.md.
Hard rules (the sandbox will enforce these)
- ✅ Use
ctx.fetch. ❌ Globalfetch,Deno.*,node:*net/fs,XMLHttpRequestare denied in the action sandbox and throw. The action worker hasreadscoped to the app dir and nothing else. - ✅ Declare every host a hook calls in
w6w.network.allow. Undeclared egress is blocked. (OAuth endpoint hosts are allowed implicitly.) Entries are exact hostnames; two wildcard forms cover APIs addressed by a per-tenant host a manifest cannot enumerate —"*.zendesk.com"matches any subdomain at any depth (not the apex), and"*"disables egress restriction entirely and is only appropriate when the endpoint is a user-supplied URL (a self-hosted install). Prefer the narrowest form that works. - ❌ Never put credentials in an Action. No
Authorizationheaders inexecute; letsigninject them. Actions cannot read the credential and must not try. - ✅
signis credential-only and network-less — mutaterequestand return it; don’t callctx.fetchfromsign. - ✅ Mark
performactionsidempotenthonestly. Usectx.invocation.invocationIdas the idempotency key when calling the service. - ✅ Keep hooks pure of ambient state — no module-level mutable singletons; a hook may run in a fresh worker each call.
Test, validate, run
Templates ship unit tests that call hooks with a mocked HookContext (a fake ctx.fetch, a
no-op ctx.log). Test the hook functions directly — no server needed.
// Deno template
const ctx = { fetch: fakeFetch, log: () => {} } as unknown as HookContext;
const out = await sendEmail.execute({
to: "a@b.com",
subject: "hi",
body: "…",
apiBase: "http://127.0.0.1:8080",
}, ctx);
assertEquals(out.status, 202);
Commands (from the template):
deno task test/npm test— unit tests.deno task validate(the@w6w/validatorCLI) — checks the manifest against spec rules.- To run against the reference runtime:
loadApp(dir)→describe(app)(public manifest, runs no untrusted code) →invoke(app, { manifestVersion: "1", app: "<id>", action: "<key>", params: {…} }). Needsdeno run --unstable-worker-options -A.
Health checks
An App may declare probes a host runs to answer “is this working?”: vendor status,
credential liveness, quota headroom. Auth.test already covers the credential case and is
derived into that surface automatically, so nothing is required of you today — adding a
service or quota check is additive. See rfcs/healthcheck.md.
Four things to know:
-
Probe an endpoint the narrowest usable credential can still reach. A check that needs a scope the credential may legitimately lack reports a working App as broken. Prefer a dedicated ping (Mailchimp’s
/3.0/ping), else a whoami that needs no scope, else the cheapest read available. -
Status hosts are not API hosts.
status.stripe.comis notapi.stripe.com, and must not be added tow6w.network.allowto satisfy a probe — a check gets its own per-hook allowlist vianetwork.allow, honoured only for an unsigned posture. -
Say so when a vendor publishes nothing. An entry with
unavailable: { reason }and no hook is a first-class answer, and a better one than a silent gap. Give itseverity: "informational", or the permanentunknownit reports will pin your App’s verdict there forever. -
Declare a status feed; don’t parse one. If the vendor publishes Atom or RSS, name it with
feedand the host fetches and parses it for you:const service: HealthCheckDefinition = { key: "service", title: "Platform status", kind: "service", feed: { url: "https://status.example.com/feed.rss" }, // host fetches + parses check({ feed }, _ctx) { if (feed?.error) return { state: "unknown", message: feed.error }; const open = feed!.latest.filter((e) => !/^status:\s*resolved/i.test(e.summary)); return open.length === 0 ? { state: "ok" } : { state: "degraded", message: open.map((e) => e.title).join("; ") }; }, };Read
latest, notentries. A feed is a log of updates, not a statement of current state: most vendors emit one entry per update, so the newest entry for a resolved incident still carries that incident’s original title, and judging by it reports an outage that ended days ago.latestis the host’s fold to one entry per incident. The feed’s host is allowlisted implicitly, so do not restate it innetwork.allow.
Triggers (advanced / optional)
Apps may also declare triggers (what starts a workflow) via TriggerDefinition. A trigger is
one of two forms, and the loader derives which (type) from the hooks you declare:
- Webhook —
onSubscriberegisters with the third party (it receives thecallbackUrlto hand over) andonUnsubscribedestroys that registration. They MUST pair: the loader rejectsonSubscribewithoutonUnsubscribe(invalid_trigger). The optionalhandleIngestturns the whole inbound call ({ method, path, query, headers, body }) into 0..N events; without it the body is the event. A trigger with no hooks is a plain receiver. - Poll —
poll({ params, state, subscriptionId }, ctx)returns{ events, nextState? }and the host calls it on an interval (minIntervalMsis your floor; the host may set a higher one). Dedupe from your own cursor instate. A poll trigger MUST NOT declareonSubscribe.
Either form may add parseOutput, which reshapes a stored event into the run’s trigger.event.
That is five hooks in all: onSubscribe, onUnsubscribe, handleIngest, poll, parseOutput.
The starter templates do not include triggers; add them only when asked, and read
rfcs/trigger.md first.
Definition-of-done checklist
- Started from a template (deno or node).
-
package.jsonw6wblock: reverse-DNSid,displayName,appearance.icon, 1–3categories,network.allowlists every host the hooks call. -
index.tsdefault-exports{ actions, auth }satisfyingAppDefinition. - Every action: unique kebab-case
key, correcttype, typedparams/output, usesctx.fetch, no credentials. - Auth (if any):
testpresent;signinjects the credential; secret fields usetype: "secret". - Unit tests pass (
deno task test/npm test) and the manifest validates (deno task validate). - Imports keep
.tsextensions.
Reference
- Templates: w6w-app-template-node · w6w-app-template-deno
- Types:
@w6w/types· Runtime:@w6w/runtime - RFCs: app · action · auth · param · connection · invocation · hook-runtime · categories · trigger
- Working examples:
fixtures/apps/hello(no-auth) ·fixtures/apps/sendgrid(apiKey + sign)