Your own consumer of a weft program
SkillWeb & browsingLets your agent let your own website or app answer, skip, or cancel tasks a running program is waiting on.
Use Your own consumer of a weft program in Claude, ChatGPT or Ahel Desktop
Free. Sign in, add Your own consumer of a weft program and connect your AI. About a minute.
Also: Claude Code · Cursor · Codex
Then ask your AI: use the Your own consumer of a weft program skill
Details
Instructions available. Your AI can read the instructions. Execution depends on the setup they require.
Account requirements not reviewed. Check the skill instructions before use; ahel provides instructions and does not run this skill.
No other account needed.
Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.
About this skill
Read when the user wants their own website, app, bot or extension to list, show, answer, skip or cancel what a program is waiting on, fetch a file it carries, or show what one of its nodes is displaying (a WhatsApp bridge's QR code, a database's minted password): the api token, the doors, the payloa
What this skill tells your AI
The instructions your AI receives, as published by weavemindai/weft in tangle/claude-code/.claude/skills/weft-consumers/SKILL.md and read by ahel’s review.
A [signal] is one thing a running program waits on: a parked question, a
trigger that starts a run, a webhook, a timer. Every [signal] has a
[signal token], the credential that fires it (the token of its listing
entry). An [api token] lets a [consumer] list the [signal]s it may see. A
[consumer] is any client of the [door]s below: the weft browser extension
is one, for the form kind; anything that can send HTTP is another, for that
kind or any kind that renders a consumer payload. A frontend under front/
(the weft-frontend skill) is one more [consumer] of exactly these
[door]s, for the human steps the program parks; the program's own Route
nodes serve its ordinary data calls (the weft-api skill).
The token
Whoever runs the project mints the [api token]:
weft token mint --name "my website"
weft token mint --name "reviewer" --projects <id> --tags approvals
weft token mint --name "my website" --display whatsapp
It prints, once, an address of the form
http://host:port/signal-token/<token>: the part before /signal-token/
is the dispatcher the [consumer] talks to, the last segment is the token.
The bare token follows on the second line. A script reads it with --json
and takes .token (weft token mint ... --json | jq -r .token): the
"Copy it now" note goes to stderr, and a shell that merges the two streams
puts it last, so tail -n 1 would read the note. The server keeps a hash only, so a lost
token is revoked (weft token ls, weft token revoke <id>) and a new one
minted. A token with no scope sees every [signal] of the tenant;
--projects and --tags narrow it, both repeat.
The display scope reads the other way round. --display and
--displays GRANT what a node is showing, and a token that names neither
reads none of them. A QR code pairs the account to whoever scans it, so it
takes an explicit word rather than arriving with the wildcard. Ask which
nodes the [consumer] should watch before minting, and mint the narrow one.
--display takes the node the way the user writes it (whatsapp, or
test.whatsapp for a node of a file included as test), and the CLI resolves
it against the project the user is standing in, so it is run from that folder.
An address is only a name inside one project, and the grant the dispatcher
stores carries both halves. Mint refuses a name no display in that project
answers to, and lists the ones it has.
Every [door] below except firing takes the [api token] as
Authorization: Bearer <token>. It never goes in a URL: proxies and access
logs would keep it. When the [consumer] is a website, it stays server side:
a browser page holding it can list every [signal] it sees.
The four doors
A [door] is one HTTP endpoint of the dispatcher. The [door]s know nothing of any kind: they list, fire, skip, cancel and link the same way whatever the [signal] is.
| [door] | What it does |
|---|---|
GET /signal-token/signals | the [signal]s this [api token] may see and that render a consumer payload, one [entry] each (shape below) |
POST /signal/{signal token} | fire one [signal] with a JSON body: answer a question, start a trigger's run, deliver a webhook's payload. No bearer: the [signal token] is the credential |
POST /signal/{signal token}/skip | fire it with no payload: a parked question resumes unanswered (the node's outputs close and downstream skips). No bearer, same rule as firing |
GET /signal-token/signals/{signal token}/files/{field} | a fresh link for a stored file a [signal]'s field shows, good for an hour. You ask every time you render and never store the link |
Two more, for a [consumer] that manages [signal]s:
DELETE /signal/{signal token} cancels the run behind a [signal] (answers
204), and
DELETE /signal-token/signals cancels every run the [api token] sees and
drops its triggers (answers { colors_cancelled, entry_signals_dropped }).
Both need an [api token] with no tag scope and full project view; a
narrowed token gets 403, because a cancel reaches sibling questions of the
same run that the token may not see. GET /signal-token/health answers
{ "ok": true } for a valid [api token].
Showing what a node is showing
A [display] is what one node shows about itself while it runs. Three more [door]s, the [api token] as bearer on all three:
| [door] | What it does |
|---|---|
GET /signal-token/displays | the displays this token may watch: { project_id, project_name, node, node_type, kind, label?, status? } each, kind being infra or trigger. An infra entry's status is where the copy behind it stands: absent (never started), provisioning (starting), running, flaky, stopping, stopped, terminating or failed. node is spelled the way a person writes it, and it is what {node} takes in the two doors below |
GET /signal-token/displays/{project}/{node} | what that node is showing right now |
POST /signal-token/displays/{project}/{node}/action | press a button one of its items carried: { "kind": "<actionKind>", "payload": ... }. Only an infra node has buttons; a trigger's [display] is read-only and answers 400 |
A display is { "items": [...] }, each item { type, label, data, action? }:
{ "items": [
{ "type": "image", "label": "Scan with WhatsApp", "data": "data:image/png;base64,..." },
{ "type": "text", "label": "Phone", "data": "not paired",
"action": { "label": "Disconnect phone", "actionKind": "unpair",
"confirm": "Detach the paired phone?" } }
] }
type is one of four and says how to draw it:
type | data is | draw it as |
|---|---|---|
text | a string | plain text, copyable |
image | a data: URI or a URL | straight into an <img src> |
progress | a number from 0 to 1 | a bar |
secret | a string | masked until the reader asks to see it, and copyable without revealing |
label is on every item. action is at most one per item and draws a
button: show confirm first when it is there, POST actionKind to the
action door, and read the display again right after, because the press is
what changed it.
const res = await fetch(`${base}/signal-token/displays/${project}/${node}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(await res.text());
const { items } = await res.json();
// pressing an item's button
await fetch(`${base}/signal-token/displays/${project}/${node}/action`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ kind: item.action.actionKind, payload: null }),
});
base is the part of the minted address before /signal-token/, the same
one every other door uses.
If the page is part of a website, its browser never calls that address: it
calls /weft on its own site, whose server passes the call on (the
pass-through in the weft-frontend skill), so there base is /weft.
Read the display on every render, never store it. A QR code expires in under a minute, and a bridge that got paired in between shows a phone number where the code was. Poll it every few seconds while the reader is looking at it, and stop when they navigate away.
Who serves it depends on kind, and a client drawing items does not care: an
infra node's own container serves it, a trigger's signal kind serves it
from the listener. What differs is what shows up. A container shows whatever
its author wrote, buttons included. A trigger with a public entry shows the
whole address a caller sends to (host, methods and all, ready to use as it is)
and how the door checks them, which is either open or a named connection and
never a secret; one that fires from inside the runtime shows what it is doing
(a timer its schedule, a poll the URL it reads and how often).
A node the token was not given, a project it cannot see and a node with
nothing to show all answer 404, so a caller walking ids learns only "nothing
here". A token that reaches no display at all gets a 403 naming the flag that
would have given it one, so ask the operator to mint a token with --display <node> rather than guessing that the project shows nothing. While there
is nothing behind the display yet the door answers 404 as well, so read the
listing's status to say which: provisioning is "starting" (a copy can take
minutes, so show that it is on its way, and read the feed again once it says
running), absent or stopped is "not started" with the button that starts
it, and failed is the error. A trigger's display 404s until the project is
activated.
What a listed signal looks like
An [entry] is one object of the listing:
{
"token": "…", // the per-task credential, used in the answer doors
"nodeId": "ask",
"kind": "form",
"consumerKind": "human_in_the_loop",
"title": "Approve this reply?",
"description": "…", // absent when the node set none
"isResume": true, // true: a parked question; false: a trigger to start a run
"formSchema": { "fields": [ … ] }
}
token, nodeId, kind, isResume and title are on every [entry]; the
rest is the kind's own payload, and kind says how to read it. isResume
splits the list: a parked question disappears once answered (a second
answer gets 404 once it is gone, 409 while the project is parked and the
first answer waits in its queue); a trigger stays listed while the project
is active and fires again and again, each fire a new run.
Which kinds appear. A kind reaches the listing when its listener
handler renders a consumer payload. Today the form kind is the only one that
does, so any node registering a form signal is listed (HumanQuery is one)
and every other kind stays out of the listing, though its [signal token]s
fire through the same [door]. A new kind is language work:
a Signal type in weft-core/src/signal/, a handler in
weft-listener/src/kinds/ whose render builds the payload (and keeps
weft's internal file references out of it), Signal::stored_file when the
payload shows files (the files [door] asks the kind, so it needs no
knowledge of any), and the node that registers it setting consumerKind,
the free label a [consumer] filters on. Once it renders, every [consumer]
that knows its kind shows and fires it; nothing else changes.
The form kind
A form's payload is formSchema.fields, each field:
{
"fieldType": "approve_reject",
"key": "ok",
"label": "Approve?",
"render": { "component": "buttons", "source": "static", "multiple": false, "prefilled": false },
"value": null, // the prefill, when the kind has one
"config": { "approveLabel": "Yes", "rejectLabel": "No", "options": ["a", "b"] }
}
render.component is what you draw; key is what you answer under.
| Kind | Draw | Prefill in value | Answer under key |
|---|---|---|---|
display | readonly: show the value, no input | the wired value (a string, or any JSON to pretty-print) | nothing |
display_image | image | a file (below) | nothing |
approve_reject | buttons, labels from config.approveLabel / rejectLabel | none | true or false |
select / multi_select | select, options from config.options; render.multiple for several | none | one option string, or an array of them |
select_input / multi_select_input | select, options from the wired value (a list of strings) | the option list | as above |
text_input / textarea | text / textarea, config.placeholder when set | none | a string |
editable_text_input / editable_textarea | the same, render.prefilled true | the wired string, shown for editing | the edited string |
The answer body is one JSON object, every answerable field present:
{ "ok": true, "reason": "looks fine", "tone": ["formal"] }. A field left
out lands as null on its port, which a typed port refuses: the port closes
with a warning in the journal and whatever reads it skips, so you send every
field. The dispatcher answers 200 on a live project, and also 200 when the
project is parked (the answer is queued and played when it wakes); 404 for
an unknown or already-answered [signal token]; 409 for a second answer
while the first waits in a parked project's queue (you show it as "someone
else answered"); 410 when the project no longer accepts anything (you show
it as "the project is gone"); 429 when a trigger's queue is full while
parked. On any other failure you show the message text.
A file in a form
A display_image field's value is one of two plain shapes, never weft's
internal file marker and never a storage key:
{ "url": "https://…", "mimeType": "image/png", "sizeBytes": 9, "filename": "cat.png" }for a file that lives at a URL: you use theurlas it is.{ "mimeType": "image/png", "sizeBytes": 7, "filename": "cat.png" }for a file weft stores: no link. You call the files [door] with the [signal token] and the field'skey; it answers the first shape with a link minted at that moment, good for an hour.
You ask the [door] each time you show the form and each time the page reloads, so a form answered a month after it parked still shows its picture. The [door] is scoped like the listing: a [signal] the [api token] lists, a field the form declares, a file that belongs to that [signal]'s project or run; anything else is 404. A file that expired or was deleted answers 404 with the store's message ("no longer available…"); you show that text where the picture would be, never a broken image.
How the reference extension behaves
extension-browser/ in the weft checkout is the worked example: a
background poll of the listing every 30 seconds per [api token], a
notification per [signal token] it has not notified before (remembered
across polls, so a flaky poll does not re-notify), a page that draws each
field by render.component, fires POST /signal/{token}, offers skip, and
asks the files [door] for every stored image as it renders. Failures are
per [api token]: a dispatcher that is down or answers the wrong shape puts
that token in a failed bucket with the reason, and the other tokens'
[signal]s still show. Its src/lib/api.ts is the smallest complete client
of these [door]s; you copy its shape.
Writing one
- Mint an [api token] scoped to what the [consumer] sees.
- Poll the listing; read each [entry] by
kind; draw byrender.component, answer bykey. - Fire with the [signal token]; show 409, 410 and other failures as above.
- For a stored image, ask the files [door] on every render.
- For a node's display, read it on every render and draw by
type. - Check
GET /signal-token/healthon connect and after failures.
Signals
- GitHub stars
- 2k
- Forks
- 219
- Last commit
- Oct 2026
Advanced
- Item type
- skill
- Key
weft-consumers- Source
- github.com/weavemindai/weft
Related picks
Skill · davepoon
The pick for Whatsappwhatsapp-ops
Skill · aaronnat23
The pick for Whatsappbrowser-use
Skill · browser-use
More in Web & browsingwebapp-testing
Skill · anthropics
More in Web & browsingplaywright-cli
Skill · microsoft
More in Web & browsingbenchmark
Skill · affaan-m
More in Web & browsing