Skip to main content

weft-consumers

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 payload shapes, and how a new signal kind reaches a consumer.

跳到安装

来源信息

仓库
WeaveMindAI/weft
最近来源活动
2026年9月26日 20:12
检测到的 SKILL.md 语言
英语
星标
1,982
分支
221

安装方式

默认使用会先检查来源的 Prompt;你也可以切换为直接命令,或下载本地副本。

检查来源文件

决定是否安装前,请先阅读 SKILL.md,以及 SkillsMP 当前展示的配套文件。

正在显示 SKILL.md

SKILL.md
来源说明 · 只读预览
name
weft-consumers
description
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 payload shapes, and how a new signal kind reaches a consumer.
# Your own consumer of a weft program 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]: ```bash 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? }`: ```json { "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. ```js 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: ```json { "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: ```json { "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 the `url` as 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's `key`; 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 1. Mint an [api token] scoped to what the [consumer] sees. 2. Poll the listing; read each [entry] by `kind`; draw by `render.component`, answer by `key`. 3. Fire with the [signal token]; show 409, 410 and other failures as above. 4. For a stored image, ask the files [door] on every render. 5. For a node's display, read it on every render and draw by `type`. 6. Check `GET /signal-token/health` on connect and after failures.
在 GitHub 查看