Programs with members

SkillDatabases & data

Lets your agent build one program that serves many people, giving each their own settings, connections and schedules.

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 Programs with members skill

About this skill

Read when a program must serve many people, each with their own account, number, bot, database, spreadsheet or schedule (a WhatsApp assistant for many customers, a Slack bot per workspace, a report per client): `@per_member` (a container each), `@member_filled` (a value each, a connection included),

What this skill tells your AI

The instructions your AI receives, as published by weavemindai/weft in tangle/claude-code/.claude/skills/weft-members/SKILL.md and read by ahel’s review.

A [member] is one person a program serves, named by an id the program picks (user-42). weft keeps no list of members: an id counts as a member as long as weft still holds something for it (a copy, a value, a token, a run).

You never copy a program once per person: you mark what belongs to one person, in one of two ways.

What you mark

@per_member goes on its own line in the braces of an infra node, and gives each member their own container. Anywhere else it is refused as per-member-ineligible.

@member_filled goes where a field's value would, and makes that value each member's own: their connection, their spreadsheet, their model, their schedule. @member_filled(<value>) names what a member who gave none gets (it wins over the node's default); bare @member_filled leaves such a member's field empty. The fallback may be a file, @member_filled(@file("prompts/default.md")) (or @asset(...)), read the way a written one is; no other marker goes inside. An empty field then takes the node's default if it has one, stays empty if the field is optional, and otherwise the run is refused, naming the field. A wired field cannot be @member_filled (member-filled-wired), nor a group's, loop's or included file's own port (member-filled-boundary), nor a key that is not an input (member-filled-not-an-input). For a group, loop or include port, mark the input of the node inside that reads it.

You never mark the steps downstream: every node reading a member's copy or a member's value follows it and runs in that member's runs. A trigger reading a member's bridge, or a trigger with a @member_filled field, is itself per member.

bridge = BaileyBridge {
  @per_member
}
receive = BaileyReceive
receive.endpointUrl = bridge.endpointUrl

openrouter = OpenRouterProvider {
  connection: @member_filled
  model: @member_filled("deepseek/deepseek-v4-flash-0731")
}
digest = Cron { cron: @member_filled("0 0 8 * * *") }

On a connection field, what you write decides whose key pays for a member's calls, so ask the user which they want before writing it:

  • connection: {"id": "<connection id>"}, not marked: every member runs on the user's key;
  • connection: @member_filled: each member connects their own and pays, and a member with none is refused, naming the field;
  • connection: @member_filled({"id": "<connection id>"}): a member who connected nothing runs on the user's connection, one who did on their own.

If a member should run on the user's key, the source has to say so: a member can never pick the user's connection, or the runtime's shared key, for themselves. The member door refuses the shared key, and a value saved for a member's connection field has to be a connection that member owns, whoever saves it (SetMemberValues included). If the user's connection is one made with the runtime's shared key, the members it serves spend that key.

A member's value is held to the node's own rules when the member gives it (refused on the spot, with the node's message), and again when a run for them starts; a fallback is checked at compile time like any written value. A run keeps the values it started with. When a member changes a value one of their live triggers reads (its own field, or anything its setup goes through, like its connection), weft sets that trigger up again before the change returns, so you never reactivate it yourself, and events arriving meanwhile wait and go through on the new value.

Who a run is for

A run is for one member or for nobody, stamped by what started it: weft run --member <id>; an event arriving through a member's own copy; a live route called with a member token in Weft-Member-Token; a live route gated by a connection called with Weft-Member: <id> (the author's own backend; an open route refuses the header). Everything else runs for nobody. A run is refused before it starts when it reaches something per member and is for nobody, when the member has not filled a field the run needs (or filled it with a value the node refuses), or when the member's infra copy is down. Each refusal names its fix. Hand that message to the user unchanged.

An event reaching a member's trigger before they filled a field it needs is held, not dropped, and never retried on a timer: it runs when the member's values change (or the trigger is activated again). weft status shows under the trigger how many events wait and for which field, and ctx.members().list() (the ListMembers node) hands the same to the program, so the user's website can tell the member what is missing. An event held because the member's copy is down retries on its own until the copy is up.

weft never starts a member's copy by itself: the program does, or the user with --member on the infra verbs. A copy runs from images that weft activate builds and records for every node marked @per_member, so activate the program before it starts any copy; without that, the start fails naming weft activate.

The shape you build

A program with members needs two parts, and sometimes a third. Propose the first two every time, and the third only when the user has a rule for it.

  1. The per-member part: what you mark @member_filled (and @per_member on an infra node), and the steps that follow. When a member's trigger reads a value they fill (a schedule, a sheet), give that field a fallback if one makes sense. Then you can turn the trigger on the moment the member joins, before they have saved anything. Without a fallback, turning it on is refused, naming the field, until they have saved it. When a member saves a value, weft restarts their triggers that are on so they use it, and leaves the ones that are off alone.
  2. An admin route the user's own backend calls, gated by a credential (ApiKeyAuth on the Route's auth). It onboards a member: MintMemberToken, then Reply with the token so the request ends at once, then StartMemberInfra if the program has a per-member infra node (it fires done once the copy runs), then ActivateMemberTriggers. A copy can take minutes to come up, so the website shows the copy coming up by reading the status of that member's displays, and the request never waits for it. For the displays listing, go and read the weft-consumers skill. If a trigger needs a value with no fallback, the route's activate step belongs in a second call the website makes once the member has saved their settings. The same route offboards with WipeMember: only the user's backend knows when a member leaves, so leaving always comes through here.
  3. A timer, only for a cleanup rule the user names and weft can see: copies in failed (ListMemberCopies), members over a spending limit (MemberCosts), or members with events waiting on a field they never filled (ListMembers), stopped with StopMemberInfra or removed with WipeMember. weft does not track how long a copy sat unused, so do not offer "idle copies" as a rule.

The member nodes are in the members package. A program that needs something they do not cover calls the ctx directly in a node of its own:

CallWhat it does
ctx.member()Who this run is for (the CurrentMember node fires member or nobody)
ctx.infra("bridge").member(id).start() / .stop(spec, stop_self) / .terminate(spec, stop_self) / .status()One member's copy
ctx.infra("bridge").copies()Every copy (the ListMemberCopies node)
ctx.triggers().member(id).activate() / .deactivate(spec, stop_self)A member's triggers (.only([..]) narrows)
ctx.values().member(id).get() / .set(step, field, value) / .clear(step, field) / .apply() / .forget()What a member gave for the @member_filled fields: read (the GetMemberValues node), change in one go (the SetMemberValues node; apply() returns the triggers it set up again), or forget all
ctx.connections().member(id).list() / .forget()A member's connections (forgetting takes the values naming them)
ctx.members().list()Every member weft holds anything for, with counts and states and the events waiting on a field they have not filled (the ListMembers node)
ctx.costs().member(id).service(s).since(t).list()What a member cost (the MemberCosts node, with the total)
ctx.runs().member(id).clean(running, stop_self)A member's runs
ctx.tokens().mint_for_member(id, expires_in) / .member(id).revoke()Member tokens
ctx.storage(StorageScope::member())The run's member's own files

start() returns once the copy is running, however long that takes (the run parks between looks and holds no worker), so you can activate the member's triggers right after it. If the copy never comes up, start() fails with the reason, and so it does if somebody stops the copy while it waits (it never starts it again behind a pause). Every reader of a copy's state (weft status, weft infra status, .status(), MemberInfraStatus) gives one answer, and a start or stop on its way reads provisioning or stopping at once.

If the run making a take-down call is among the runs it reaches, StopSelf::Keep leaves it running and StopSelf::Include cancels it with the rest.

weft keeps only a hash of a member token, so a replayed run cannot hand back the value it minted: it mints a new value for the same token, and the old value stops working.

WipeMember removes the member's triggers, the copies you name, their values, connections, tokens, files and runs. weft rm of the project takes every member's values, connections, tokens and files with it.

Where the member fills in their values

A member fills in their values (connecting their accounts is one of them) on their own settings page, never in the editor: the browser extension's Your settings page with their member token, or the MemberSettings component on the user's own site, copied into the frontend with weft connect-lib; for mounting it, go and read the weft-frontend skill. It draws each field with its own control, and a list field (their spreadsheets, their models) is read through their own connection. A member is never offered the shared key. To try a member's path yourself before any website exists: weft connect --member <id> --node <step> connects an account as that member (--door own --set-env key=ENV_VAR) or picks one they have (--grant <id>), and weft member-values --member <id> lists their fields, with --set step.field=value / --clear step.field changing them. Each command mints its own one-hour member token and revokes it when done, so the member's own tokens are untouched.

Checking your work

weft infra status lists each member's copy on its own line; weft status lists every infra node the program declares under infra: (a @per_member one with how many members have a copy, one never started as not started), each member's copy under member copies:, and each member's triggers under triggers:, with the events a trigger holds for a missing field. weft executions --member <id> lists a member's runs. To try the per-member path yourself, start a copy (weft infra start --member test-1), turn on its triggers (weft activate --member test-1), and run with --member test-1.

Signals

GitHub stars
2k
Forks
221
Last commit
Sep 2026
Advanced
Item type
skill
Key
weft-members
Source
github.com/weavemindai/weft