wrapflow
v0.3.74
Published
Wrapflow SDK, wf CLI, and MCP tools for building, inspecting, connecting, and safely releasing one typed App.
Maintainers
Readme
wf — build one complete Wrapflow App
An App is Flows written in the Wrapflow flow language plus an uploaded frontend.
Read with describe kind:"flow" → edit the text → apply {text, expectedRevision} → test → publish.
Every command declares fields, including fields: [] for no inputs. Delete requires [] and takes only recordId/key plus expectedVersion where used; create/patch list their inputs explicitly.
Use the returned updatedAt as expectedRevision; keep exported layout and metadata.
Publish uses the existing owner approval.
A text field holds up to 500,000 characters in one value (max: sets its limit in flow text, maxLength in define JSON; the default is 2,000), so write long text whole and never split it across fields. Above 10,000 characters a field is long text: lists and queries omit it unless select names it, and it cannot be unique, filtered, sorted or searched. One table's long text fields declare at most 500,000 characters together.
Example:
let habits = table(name: "Habits", fields: [text(name: habit_name, required: true, max: 80)])
let entries = table(name: "Entries", fields: [text(name: habit_name), number(name: score)])
let positive = filter(input: entries, where: score > 0)
let kept = aggregate(input: positive, by: [habit_name], total: sum(score))
let joined = join(left: habits, right: kept, type: left, on: habit_name, take: [total], rightDedupe: none)
let result = show_table(input: joined, columns: [habit_name, total], limit: 100)The ordinary wf surface is four commands, one App journey:
npm install --save-dev --save-exact --ignore-scripts 'wrapflow@<reviewed-version>'
# Invoke only the exact reviewed project-local package. Do not ask npx to
# resolve `wf`: when this dependency is absent, it can select an unrelated package.
node ./node_modules/wrapflow/wf.mjs login --url https://<host> --email [email protected] # securely prompts for password
node ./node_modules/wrapflow/wf.mjs app create "Client request portal" \
--intent "Build a client request portal where clients submit a request with title, details, priority, and due date; operators can review status and ownership. Include safe demo requests clearly marked as demo data." \
--request-id build-client-request-portal-2026-07-17 \
--host self
node ./node_modules/wrapflow/wf.mjs app preview <app-id> # private draft URL
node ./node_modules/wrapflow/wf.mjs app publish <app-id> # readiness → proof → release
node ./node_modules/wrapflow/wf.mjs app status <app-id> # live hosting statewf app create makes the hosting decision exactly once: --host wrapflow
("Host my App with Wrapflow") or --host self ("Connect my own frontend").
An interactive terminal is asked the one question; a non-interactive
--intent build infers self. No later command asks again. wf app publish
drives the full release authority path — readiness and exposure review, then
isolated browser checks (--checks --reviewed-exposure), then the exact
approved authority document (--authority release-authority.json, admin
token). Nothing about check or the operator boundary is skippable.
Everything else lives under explicit namespaces: wf app data … (records in
and out), wf app integration … (endpoints, typed clients, connectors, MCP),
wf app operations … (runs, results, reviews, access, incidents, hosted-backend traffic),
wf dataset … (saved datasets shared across Apps — wf dataset delete <id>
--yes, admin token), and wf advanced …. Run each with --help for its
commands.
Old command spellings
Every pre-existing spelling keeps working as a compatibility alias until 2026-11-01 and prints one migration line on stderr naming its replacement:
| Old | New |
| --- | --- |
| wf app build | wf app create --intent "…" |
| wf app list | wf app status |
| wf app authority / wf app checks / wf app release | wf app publish (--checks, --authority) |
| wf app inspect/platform/access/user/incident/fixtures | wf app operations <same> |
| wf app connector/client/contract | wf app integration <same> |
| wf app attach/edit/get/save | wf advanced <same> |
| wf publish (share URLs) | wf app integration share |
| wf api/stream/run-api/scope/invoke/job/mcp | wf app integration <same> |
| wf data | wf app data snapshot |
| wf query/mutate/transact/submit/call/log/import/export/backup/restore/reset | wf app data <same> |
| wf run/runs/reviews/review/result/latest/open | wf app operations <same> |
| wf list/create/release/release-review/define/undefine/remove/describe/test | wf advanced <same> |
The sections below document the underlying operations; their examples use the historical spellings, which remain exact aliases of the commands above.
Inspect and integrate a draft
node ./node_modules/wrapflow/wf.mjs app inspect <app-id>
node ./node_modules/wrapflow/wf.mjs app contract <app-id>
node ./node_modules/wrapflow/wf.mjs app client <app-id> --file src/wrapflow.generated.ts
node ./node_modules/wrapflow/wf.mjs app edit <app-id> \
"Add client-visible status history and a weekly overdue queue" \
--revision <current-frontend-revision> \
--frontend externalReplace <reviewed-version> with one exact reviewed version and commit the
lockfile. Do not resolve the CLI from latest or another mutable tag.
Keep the App ID and frontendLane, frontendRevision, and
frontendUpdatedAt returned by inspect. This
quickstart intentionally stops at a private draft. Continue to the guarded
complete App lifecycle below only when its authority and publishing preflight
are available.
Deterministic authoring: no AI call, no AI credits
app build and app edit call a model. When the product structure is already
known, or the entity and command schemas must be exact rather than inferred,
author the same App deterministically instead. None of these commands spend AI
credits:
For a single typed object, create the App container and initialize its own backend before defining it. For whole-Flow changes, use the text path above:
APP_ID=$(node ./node_modules/wrapflow/wf.mjs app create "Client request portal" --frontend external)
# stdout stays the pipeable App id; stderr confirms external lane revision 1.
BEHAVIOR_ID=$(node ./node_modules/wrapflow/wf.mjs create --empty --name "Requests")
node ./node_modules/wrapflow/wf.mjs app attach "$APP_ID" "$BEHAVIOR_ID"
# Attach the still-blank behavior before define makes its graph App-aware.
node ./node_modules/wrapflow/wf.mjs describe "$BEHAVIOR_ID" entity # exact JSON schema + current definitions + updatedAt token
node ./node_modules/wrapflow/wf.mjs test "$BEHAVIOR_ID" entity '{"spec":{…}}' # preflight a candidate spec; mutates nothing
node ./node_modules/wrapflow/wf.mjs define "$BEHAVIOR_ID" entity --file entity.json \
--expected-updated-at <token-from-describe> # apply one business-level object
node ./node_modules/wrapflow/wf.mjs define "$BEHAVIOR_ID" command --file command.json
node ./node_modules/wrapflow/wf.mjs describe "$BEHAVIOR_ID" view # inspect declared views and JSON schema
node ./node_modules/wrapflow/wf.mjs define "$BEHAVIOR_ID" view --file view.json # define server-side computed/aggregate view
node ./node_modules/wrapflow/wf.mjs test "$BEHAVIOR_ID" view '{"name":"status-totals","context":{"entities":[{"name":"orders","rows":[{"item":"A","status":"shipped","amount":50}]}]}}' # test view evaluation with ephemeral rows
node ./node_modules/wrapflow/wf.mjs query --flow "$BEHAVIOR_ID" --view status-totals # query declared view
# Make this behavior start when rows land in an App table and write to another (a chain; see docs/BUILD-WITH-WRAPFLOW-AS-AN-AGENT.md)
node ./node_modules/wrapflow/wf.mjs define "$BEHAVIOR_ID" chain '{"name":"refine","from":"raw","to":"refined"}'
node ./node_modules/wrapflow/wf.mjs describe "$BEHAVIOR_ID" chain # "Refine runs when rows land in raw and writes to refined."
# Remove obsolete actions, entities, views or chains from a draft
node ./node_modules/wrapflow/wf.mjs describe "$BEHAVIOR_ID" command
node ./node_modules/wrapflow/wf.mjs undefine "$BEHAVIOR_ID" command obsolete-action \
--expected-updated-at <updatedAt-from-describe> # remove declared command from draft
node ./node_modules/wrapflow/wf.mjs describe "$BEHAVIOR_ID" entity
node ./node_modules/wrapflow/wf.mjs remove "$BEHAVIOR_ID" entity temp-table \
--expected-updated-at <updatedAt-from-describe> # remove declared entity (refused if referenced)
node ./node_modules/wrapflow/wf.mjs remove "$STRAY_BEHAVIOR_ID" # delete an owned stray standalone draft background appThe stable kinds are entity (named durable collection with typed fields,
relationships, and form/table/edit operations), command (named atomic write
with typed input and guards) and view (named server-run read over one entity:
fixed where clauses, which may compare two fields of the same row such as
stock_level lte reorder_threshold, compare a date field to today
("relativeTo":"today", UTC, read on every read), or filter on a field of the
related row one reference hop away ({"relation":"company","field":"customer_tier",
"op":"eq","value":"gold"}; two hops are refused), plus columns or counted/summed
aggregate groups; pages read it with POST /api/public/flow/<token>/query
{"view":"<name>"}, recipe in docs/BUILD-WITH-WRAPFLOW-AS-AN-AGENT.md) and
rule (a named backend rule that something is NOT happening: a record's date
has passed while where clauses still match, a record was not updated for a
duration, or a table got no new row for a duration; the server checks it about
every 5 minutes in UTC, alerts the owner once per record or table, clears when
it stops being true, and describe lists each rule in plain words with its open
incidents).
To share a table with another App in the account (by reference, no copy), list
that App's id in the entity's readers and in the grants of each command it
may run. Draft sharing uses the source's preview rows before either App is
released. Define warns that public access requires releasing the source with
the grants. After that release, read the reader's App Contract and generate its client: shared reads and
commands appear with sourceApp.id and use ordinary generated getters on the
reader's own routes. No records are copied and no direct table writes are granted.
The worked workshop/reception example in
docs/BUILD-WITH-WRAPFLOW-AS-AN-AGENT.md includes both calls and the retained
public routes. Always describe first for the exact schema of a
kind, test a candidate before applying it, and pass the updatedAt token from
wf describe <app-or-behavior-id> <kind> as --expected-updated-at to wf define
or wf undefine. When using a token, redefining or removing an existing object
requires that flag; omitting it returns 428 definition_revision_required.
First-time definitions do not require it. Read the current definition and its
revision before each change so a person's newer edit cannot be overwritten
silently. The CLI forwards the revision you supply and never fetches it for you.
The two payloads are deliberately not the same shape: test takes a scenario,
so a candidate definition must be wrapped as {"spec":{…}}, while define
takes the bare spec, so entity.json or view.json holds the spec object itself with no
spec wrapper.
A command can take an amount from one record of another entity in the same
save — an order taking qty from inventory.stock_level, refused with your
message when less than 0 would be left — with a take guard:
{"name":"place-order","entity":"orders","guards":[{"source":"inventory",
"where":[{"field":"_wf_record_id","op":"eq","valueField":"product"}],
"column":"stock_level","take":{"amountField":"qty"},"message":"Not enough stock for this order."}]}An order with several lines carries one take guard per line (product_1/qty_1, product_2/qty_2, ...):
all lines are taken or none. Without a message the refusal names the short item.
A computed aggregate view defines server-calculated sums, counts, and groups:
{
"name": "status-totals",
"entity": "orders",
"aggregate": {
"groupBy": ["status"],
"metrics": [
{ "op": "count" },
{ "op": "sum", "field": "amount" }
]
}
}test on that command reports relatedChanges (stock_level from → to)
without writing. test on a view evaluates the view over current rows (or ephemeral context rows)
and returns { rows, total, groups }. The full recipe is in docs/BUILD-WITH-WRAPFLOW-AS-AN-AGENT.md.
Node ids and storage keys inside the behavior never cross this boundary — these
verbs are the supported deterministic interface, unlike raw graph JSON. MCP
exposes the matching create_app, attach_behavior, define, describe, and test
tools; pass
frontendLane: "external" to create_app for an App-owned frontend. The SDK
exposes createApp, createEmpty, define, describe, and test; call
wf.createApp({ name, frontendLane: 'external' }) for the same App container
and external authority, and use
wf.request('/api/apps/<app-id>/flows/<behavior-id>', { method: 'PUT' }) for
the historical attachment route. Omitting the lane on any deterministic
creation surface preserves the empty container with no frontend authority.
Test a draft as someone not signed in
MCP query_entity, mutate_entity, and call_command accept appId with
draftAs: "anonymous" instead of tokenOrUrl. For example:
{"appId":"app-1","draftAs":"anonymous","entity":"tasks","limit":20}This calls the current draft with account authentication for draft access and
x-wrapflow-draft-as: anonymous for the visitor's permissions. It sends no
App-user session and needs no sign-in setup. Pass pipelineId when the App
has multiple Flows. Queries use JSON; CSV queries require a published capability.
An ownershipToken can identify a returning anonymous submitter. Server access
refusals are returned unchanged. Do not combine the draft target with
tokenOrUrl or appUserToken.
The SDK exposes the same path as
wf.callAsAnonymous(appId, {flowId, entity, action: 'query' | 'mutate', input})
or {flowId, command, input}; input is the runtime request body. A wfd_
value identifies a draft revision for release checks; it is not a capability.
define behaves differently on an App-owned behavior
define is App-aware. The command, the spec, and the returned definition are
identical either way, but the persisted behavior graph is not:
- On an App-owned behavior it keeps only backend nodes —
source.collectionandoutput.edits. The App-owned frontend presents them through the typed App Contract. - On a standalone background app it additionally keeps the retained canvas
presentation nodes
output.inputandoutput.table, so the app renders on its own/f/view.
That difference decides the command order above. wf create --empty initially
makes a standalone background app, but its blank graph is eligible for
wf app attach. Attach it before the first define; defining an entity while
it is still standalone writes output.input and output.table, and a later
attachment is rejected with 409 app_backend_presentation_forbidden. No
supported verb strips those presentation nodes.
The two App behavior routes are not the same route
| Route | What it does |
| --- | --- |
| POST /api/apps/<app-id>/flows | Creates a new blank behavior already owned by the App. Body {name?, description?}, returns the behavior with status 201. This alternative direct starting point is reachable through the SDK request escape hatch. The route name is historical. |
| PUT /api/apps/<app-id>/flows/<flow-id> | Attaches an existing background app as a behavior, keeping its ID, runs, releases, and compatible endpoints. wf app attach <app-id> <behavior-id> and MCP attach_behavior wrap it. Returns {ok:true,appId,flowId}. It rejects a behavior graph containing presentation nodes. The route and response field names are historical. |
The create route still has no dedicated CLI or MCP verb; the deterministic
sequence above creates a blank standalone behavior, attaches it, and only then
defines it. Attachment requires draft scope and Preview authority. Attaching
to an inaccessible App is refused as 404 app_not_found. Reattaching an
otherwise eligible behavior already owned by the same App succeeds
idempotently: the server reapplies its disabled tokenless App policy and returns
the same App/behavior
identity, though the behavior update timestamp advances.
Release is unchanged: a definition only edits the draft, so run and release the behavior (or take the App through its guarded lifecycle below) afterwards.
Advanced: background apps and recovery
Use the lower-level background app path only for a standalone transformation, report, scheduled job, or recovery operation rather than a complete App:
node ./node_modules/wrapflow/wf.mjs list
node ./node_modules/wrapflow/wf.mjs create "log my gym sessions: exercise, sets, reps, weight"
RUN_ID=$(node ./node_modules/wrapflow/wf.mjs run <id>) # defaults to draft; exact successful draft run id on stdout
node ./node_modules/wrapflow/wf.mjs release-review <id> # defaults to latest successful draft run (or pass --from-run)
node ./node_modules/wrapflow/wf.mjs release <id> # atomically seals the latest draft run into the LIVE release (or pass --from-run)
node ./node_modules/wrapflow/wf.mjs schedule <id> --cron "0 2 * * *" [--tz UTC] # recurring schedule (or --disable)
node ./node_modules/wrapflow/wf.mjs schedule <id> # read the current schedule
node ./node_modules/wrapflow/wf.mjs stream <id> --as gym # webhook intake on, alias saved
node ./node_modules/wrapflow/wf.mjs log gym exercise=squat sets:=5 reps:=5 weight:=100
node ./node_modules/wrapflow/wf.mjs run <id> --target live # or: cat fresh.csv | node ./node_modules/wrapflow/wf.mjs run <id> --target live
node ./node_modules/wrapflow/wf.mjs run-api <behavior-id> --input <file-or-sample-step-id> # admin: save authenticated request-body target
node ./node_modules/wrapflow/wf.mjs result <run-id> --csv
node ./node_modules/wrapflow/wf.mjs latest <id> --csv # newest readable result of the live release
node ./node_modules/wrapflow/wf.mjs run <id> --target live --download file > out.csv
node ./node_modules/wrapflow/wf.mjs backup <id> --dir backups/prod # exact restorable entity state + history
node ./node_modules/wrapflow/wf.mjs restore <id> --dir backups/prod --yes
node ./node_modules/wrapflow/wf.mjs export <id> --dir backups # every record of every entity → CSV files
node ./node_modules/wrapflow/wf.mjs import data.csv --pipeline <id> --collection <nodeId> [--replace] # --replace needs admin-scope token
node ./node_modules/wrapflow/wf.mjs reset <id> --yes # DANGER: clear all records/submissions (go-live step)wf import bulk-loads rows into one collection entity (CSV or JSON). It uses the
owner bulk ingest API: typed validation, key-column upserts, optional --replace
(requires an admin-scope token) to clear the entity first, and the same 5,000-row / 2 MB caps as the HTTP route.
wf run-api is the supported owner control for file-bearing authenticated Run
API calls. It validates the chosen File or Sample step against the behavior's
current Live release, persists that exact target, and only then prints no-body,
raw CSV, raw JSON, and multipart examples. An unrelated Run API update keeps the
saved target. If that step is later deleted or changes kind, Studio, the control
API, and the Run API report the stale setting and ask the owner to choose and
save a current target. The route's pipelineId field remains only as its
historical API identifier; user-facing output calls the resource a behavior.
wf release performs the same review automatically and passes only the
server-authored plan hash into the commit. SDK callers can use
reviewRelease(id,{createdFromRunId}) followed by
release(id,{createdFromRunId,dataMigrationPlanHash}); MCP exposes
review_workflow_release and applies the same sequence in release_workflow.
Constraint tightening validates every bounded live row. A violation prints the
exact rejected count and bounded record/field/reason sample, leaves v1 and all
rows intact, and must be remediated explicitly with wf mutate, SDK
mutateEntity, or MCP mutate_entity before rerunning and reviewing. A racing
write stales the plan. Default backfills and constraint checks commit atomically,
and the release receipt lists both exact operations.
wf export is the portable data exit hatch: readable current-state CSV/JSON,
but no tombstones, retained event history, or idempotency state. wf backup
is the disaster-recovery path. It freezes each Durable Object before paging it,
writes NDJSON files with row counts and SHA-256 hashes, and publishes
manifest.json last so an interrupted directory is never mistaken for a
complete bundle. wf restore validates the entire bundle before opening any
restore session and requires --yes; each entity swaps atomically after all of
its rows are staged. Apps with multiple entities commit those entities in
manifest order, since Cloudflare does not provide a transaction across Durable
Objects. The current and backed-up entity sets must match exactly by default;
--allow-partial is an explicit escape hatch that prints every excluded entity.
A private 0600 journal in the bundle records each per-entity commit. Re-run
the identical command after an interruption to resume deterministically; a
completed journal makes later re-runs no-ops after integrity preflight.
Advanced owner SDK
import { Wrapflow } from 'wrapflow'
const wf = new Wrapflow({ url, token: process.env.WRAPFLOW_TOKEN })
await wf.configureRunApiFileTarget(behaviorId, 'orders') // owner/admin control plane
await wf.push(streamToken, [{ exercise: 'bench', sets: 3, reps: 8, weight: 70 }])
const run = await wf.run(pipelineId, { target: 'live' })
const { table } = await wf.result(run.id)
// Or run a draft with request input (for an app with a Request step; published Request apps are invoked via their invoke-v1 entrypoint):
const draftRun = await wf.run(pipelineId, { target: 'draft', request: { city: 'London' } })
// Or let an agent/script read the newest successful result without tracking a run id.
const latest = await wf.latestResult(pipelineId)
console.log(latest.run.id, latest.table)
// Let Claude/Codex/Cursor edit the exact HTML document without buying model
// calls from Wrapflow. Compare-and-swap prevents two editors losing work.
const frontend = await wf.getAppFrontend(appId)
const saved = await wf.saveAppFrontend(appId, editedHtml, {
expectedRevision: frontend?.revision ?? null,
})
console.log(saved.revision)latestResult(pipelineId) asks the server for the current published version's
newest readable successful result across retained history. Draft/manual results,
results from older releases, and success rows whose saved result artifact has
expired are never substituted. The response carries both run and version
provenance alongside the canonical table. It throws an explicit
WrapflowError when the app or behavior is unpublished or its published
version has not succeeded yet. The CLI equivalent is
wf latest <app-or-behavior-id> (pretty JSON by default, or CSV with --csv);
it prints run/release provenance to stderr so
stdout remains clean table data for pipes.
push() needs no API token at all — the wfh_ stream token in the URL is the
credential, so a cron job, a container (viralwalk), or an IoT device can log
entries with one fetch.
CLI key=value inputs stay strings so identifiers such as 00123, phone
numbers, and large account numbers remain lossless. Use key:=<JSON> when a
typed value is intentional: count:=5, done:=true, missing:=null, or
meta:='{"source":"cli"}' (quote JSON objects for your shell). Unsafe object
keys and non-finite numbers are rejected before any request is sent.
Requires Node 20.19+ (the repo's engines range). Every request times out after
30s and buffered responses are capped at 50MB; tune both with
new Wrapflow({ url, token, timeoutMs, maxResponseBytes }) (timeoutMs: 0
disables the timeout).
Complete App lifecycle
The same App document used by the browser workspace is available to any coding
agent through wf mcp, the SDK, or the CLI:
node ./node_modules/wrapflow/wf.mjs app build --name "Client request portal" \
--intent "Build a client request portal where clients submit a request with title, details, priority, and due date; operators can review status and ownership. Include safe demo requests clearly marked as demo data." \
--request-id build-client-request-portal-2026-07-17 \
--frontend external
node ./node_modules/wrapflow/wf.mjs app inspect <app-id>
node ./node_modules/wrapflow/wf.mjs app contract <app-id>
node ./node_modules/wrapflow/wf.mjs app client <app-id> --file src/wrapflow.generated.ts
node ./node_modules/wrapflow/wf.mjs app edit <app-id> "Add client-visible status history and a weekly overdue queue" --revision 1 --frontend external
node ./node_modules/wrapflow/wf.mjs app fixtures <app-id> # exact proven/ambiguous data + fingerprints
node ./node_modules/wrapflow/wf.mjs app fixtures decide <app-id> \
--request-id release-review-2026-07-12 \
--decision clear \
--target 'flow-id|collection-id|fixture-v2:copy-the-full-fingerprint'
# Do not run `wf app checks` before this capability preflight succeeds.
node ./node_modules/wrapflow/wf.mjs app authority <app-id> > release-preflight.json
# Stop at the draft unless proofStartAvailable is true, an authorized publishing
# path and present operator are available, and that operator reviewed the exact
# anonymous/authenticated exposure, identity, Checkout, and file capabilities.
node ./node_modules/wrapflow/wf.mjs app proof <app-id> --request-id proof-client-request-portal-2026-07-12 --reviewed-exposure
node ./node_modules/wrapflow/wf.mjs app authority <app-id> > release-review.json
# Poll authority until the exact returned proofId is eligible. Present the
# current exposure to the operator again, then construct and explicitly approve
# the exact nine-key AppReleaseRequestV2 as release-authority.json;
# release-review.json is evidence, not the request. Stop at the draft if
# authority, proof, operator review, or publishing becomes unavailable.
node ./node_modules/wrapflow/wf.mjs app release <app-id> --authority release-authority.json # admin approval
node ./node_modules/wrapflow/wf.mjs app status <app-id> # hosting check
# Low-level HTML-only escape hatches remain available:
node ./node_modules/wrapflow/wf.mjs app get <app-id> --file index.html
node ./node_modules/wrapflow/wf.mjs app save <app-id> --file index.html --revision 2The generic SDK checkout uses the same identity protocol as wrapflow-app.
Authenticated Apps pass the App-user JWT as appUserToken. Anonymous callers
use an idempotency key; the first result includes an opaque ownershipToken
that should be stored privately and supplied for exact replay after restart:
const checkout = await wf.checkout('wfh_app', 'subscription', {
idempotencyKey: crypto.randomUUID(),
appUserToken: currentAppUserJwt(), // omit for an anonymous App
})
await wf.checkout('wfh_app', 'subscription', {
idempotencyKey: savedKey,
ownershipToken: checkout.ownershipToken,
})Never log either token or put it in a URL. Amount and currency remain server-declared; exact idempotency replay returns the original checkout, while conflicting reuse fails.
What wf app save accepts
--revision is a compare-and-swap token, not a version number to write. Pass
--revision new for the first save, while wf app inspect still reports
frontendRevision: null, then the revision the previous save returned.
wf app save is not a file upload; the server validates the document against
the hosted single-file policy and rejects the whole save with 422
frontend_policy_violation, naming each bounded finding and its line when
available. New hosted v5 source is arbitrary visual composition in one
scriptless semantic HTML document with inline CSS. The rules that most often
fail a first save:
- A complete document is required. A fragment is rejected: the HTML needs a
<!doctype html>and both<html>and</html>. Wrapflow binds the exact server-ownedwrapflow-contractmarker after authoring, but it never invents the surrounding document. - Presentation is scriptless. Every
<script>element, inline event handler, executable URL, form, frame, popup, download, worker, or arbitrary network/navigation primitive is rejected. There is no allowed client script tag or inline-script exception in new hosted source. - Behavior is declarative. Accessible native controls and projection slots
use exact Contract hooks such as
data-wrapflow-action,data-wrapflow-field,data-wrapflow-result, anddata-wrapflow-record-*. Each name must match the typed Contract. These hooks identify actions and data; they are not a widget, component, or style taxonomy. - The trusted parent owns execution. Wrapflow's manifest-pinned shell and
compiler own the client, transport, authentication, action dispatch, typed
validation, and bounded result rendering. The saved presentation never calls
a client or constructs a route. The trusted parent shell—not the
presentation—alone loads the manifest-pinned
/_wrapflow/client.js; users must not add it to hosted source. - Resources stay bounded. Use inline CSS rather than external stylesheets
or fonts. Links may target document fragments, and supported image/CSS URL
positions may use only exact immutable generated App image paths. Other
remote resources,
srcset, and nested browsing contexts are rejected. - One document of at most 512 KB.
If the interface needs JavaScript, a framework, or a mature library, use the external App-owned frontend lane and integrate it with the generated AppClient instead of trying to satisfy this policy.
wf app build generates and prints the caller-owned request key before sending
the request. If the response is lost or ambiguous, retry the exact same name and
intent with that key via --request-id; a new product intent needs a new key. SDK
buildApp and MCP build_app require an explicit 8–200 character URL-safe key
and reject missing or unsafe keys before sending a request.
wf app authority reads GET /api/apps/<app-id>/release-authority, which
is accessible to draft-scope tokens as well as admin tokens, allowing draft
workflows to inspect release anchors and readiness. Check execution (wf app checks
/ POST /api/apps/<app-id>/proofs) and publishing (wf app release /
POST /api/apps/<app-id>/deployments), however, require an admin-scope
token; a draft-scoped token receives 403 This API token doesn't have the "admin"
permission. That 403 is the expected outcome for an agent-scoped token, and the
correct response to it is to stop with the App as a draft rather than to
escalate.
Before running CLI checks or retrying, wf app authority must present current authority and
the complete exposure to a present operator. Pass --reviewed-exposure only
after that review. The check command re-reads and displays current authority and
stops unless checksAvailable is still true. MCP follows the same order and
requires operatorReviewedExposure=true on start_app_proof.
wf app fixtures is the supported release-time demo-data review. When it
reports requiresDecision: true, release remains blocked. Copy each target's
exact flowId, collectionId, and fingerprint into a repeated --target;
the CLI never fetches or guesses fingerprints during a decision. --decision
clear deletes only independently proven fixtures and leaves ambiguous rows
untouched; --decision promote keeps the records as production data. The
required --request-id is the caller's idempotency key, so retry the identical
command after a lost response. Run wf backup <behavior-id> first when a recovery
bundle is wanted; backup/export alone does not satisfy the terminal decision.
App release uses one exact authority document; the CLI never invents or refreshes parts of it during publication:
{
"requestKey": "release-request-2026-07-12",
"slug": "client-requests",
"expectedFrontendRevision": 2,
"expectedLiveDeploymentId": null,
"expectedContract": "wfc_<43-character-hash>",
"expectedDraftIdentity": "wfd_<43-character-hash>",
"empiricalProofId": "empirical-proof-job-id",
"fixtureDecisionReceipts": [],
"dataMigrationPlanHash": "<43-character-hash>"
}Retry only with the identical document. expectedLiveDeploymentId is the
deployment observed during approval (or null when none was live), so a stale
tab fails rather than replacing a newer release. expectedDraftIdentity pins
the complete reviewed draft, including its Contract, Access policy, interface,
and behaviors. fixtureDecisionReceipts must
contain the canonical receipt ID and checksum for every accepted clear/promote
decision; never default it to an empty array when decisions exist.
dataMigrationPlanHash pins the live-record simulation reviewed immediately
before approval; any intervening write makes release fail closed.
The check run ID comes from the dedicated whole-App browser check run.
App build checks, kernel test scenarios, and endpoint scenario rehearsals may
use older check wording in retained responses, but they are not empirical release
checks and their IDs must never be passed to release.
For the chosen eligible check, review coverage.exercisedInteractions and
every item in coverage.limitations. Hosted coverage v1 means the exact saved
interface loaded. External coverage v2 is deliberately different:
mode: "external-backend", backendContractLoaded: true, and
externalFrontendExercised: false mean the deterministic internal Contract
harness ran, not the App-owned product UI. Neither form is a claim of complete
product coverage. coverage.limitations is a unique, canonical-order
subset derived from capabilities that are present in that exact App but remain
outside the executable check plan: authenticated or role-gated actions,
manual-review continuations, Checkout, ineligible or partially covered entity
mutations and transactions, file transfer, app job/cancel lifecycle,
required or server-set stateful command inputs, anonymous visitor ownership
continuity, production connections and side effects, or
interactions beyond the first 32 canonical executable actions. An absent class
is not reported as a generic boundary.
Eligible anonymous scalar entities add an entity-lifecycle interaction. That
probe uses one isolated Browser session to verify create, point readback, CAS
patch without dropping untouched fields, stale-write rejection, atomic
transaction commit and rollback (including idempotency rollback), wrong-owner
denial, delete, and final absence. It names only the exercised entity;
partial-entity-mutation-coverage remains for replace or additional entity
mutations. no-anonymous-ownership-lifecycle remains because this isolated
probe does not exercise production visitor cookie/token issuance. Its generated
values, record identities, ownership capability, cookies, and bodies never
enter retained evidence.
The product-level MCP loop is inspect_app → describe kind:"flow" →
edit text → apply {text, expectedRevision} → test → frontend draft journey →
capability-preflight get_app_release_authority → empirical checks → current
authority → publish. build_app and edit_app are optional AI shortcuts.
When compilation or validation takes longer than 45s, build_app and edit_app answer with a pending status (status: "building" or status: "editing") and their requestKey or requestId before the client's tool deadline; retry with the same parameters and key to wait for completion or receive the saved result.
A finished build_app, edit_app, or update_app_frontend reply is a short
status, normally a few KB and always under 8 KB, so an agent client reads all of
it. edit_app returns state (applied, unchanged, proposal,
clarification, or failure), applied, the new revision, a bounded
summary, changes, operationResult and error, frontend as
{revision, frontendLane, htmlBytes}, any refusal in plain words, and a
details block naming the exact call for each larger body: inspect_app with section:"frontend"
for the interface HTML (it arrives in get_result_page pages when over 64 KB),
describe kind:"flow" for the Flow text, or inspect_app for structure. Interface HTML
and graphs are never inlined; a reply that would still be too big drops its
optional parts in a fixed order and lists them in omitted. A sensitive
proposal keeps the stateless signed proposal.token inline only while it is
2 KB or smaller; a larger token is replaced by tokenOmitted: true and
tokenBytes, and the owner reviews and approves the whole proposal at
proposal.approval.url while signed in (an API token cannot apply it), after
which get_edit_proposal_status reports applied. update_app_frontend
answers {saved: true, frontend: {revision, htmlBytes, ...}} and no longer
echoes the HTML back; build_app keeps app, flow: {id, name}, frontend
(compact), and proof under their existing names. Existing field names are
unchanged; what changed is that frontend.html and flow.graph are no longer
inlined in build_app and update_app_frontend replies (edit_app already
left them out).
Stop at the draft when authority, checksAvailable, an
authorized publishing path, or present-operator review is unavailable. Present
the full exposure block before checks and again before release; MCP release
requires operatorReviewedExposure=true as an explicit acknowledgement.
generate_app_client returns
the checked TypeScript adapter for React, Angular, Vue, or another local
project. inspect_app returns frontendLane, frontendRevision, and
frontendUpdatedAt, so an external project can resume without fetching the
saved handoff HTML. inspect_app with section:"frontend" and update_app_frontend remain HTML-only escape
hatches with optimistic concurrency; a stale revision gets HTTP 409 instead
of silently overwriting another tab.
export_view_csv (pipelineId, view, optional orderBy and
testUserToken; SDK exportViewCsv(appId, { flowId, view, … })) downloads a
named view of the draft as CSV: the rows its JSON read returns for the caller,
the view's columns in order, spreadsheet formulas neutralised, 10,000 rows at
most (a larger view is refused in plain words with 413, never cut short).
describe lists the route under csvExports for each view and the App Contract
lists it under exports; a released App serves the same
POST /_wrapflow/flows/<flowId>/views/<view>/export.csv on its own origin.
Use a read + draft API token for an AI. It can inspect backend context and
edit HTML, but publishing a public *.wrapflow.app deployment requires an
admin token and explicit operator approval. Never put an account API token
inside generated HTML.
Hosted v5 source contains only scriptless semantic HTML, inline CSS, and exact
data-wrapflow-* Contract hooks. Wrapflow's trusted parent shell owns the
client, transport, authentication, actions, and result projection; presentation
bytes receive no account token, route-building authority, or raw response.
JavaScript and framework projects belong in the external frontend lane: install
the browser-safe wrapflow-app runtime through the generated adapter and use
its named resources. The external client and hosted compiler both verify
/_wrapflow/contract.json; OpenAPI 3.1 is at /_wrapflow/openapi.json.
Transport paths, auth headers, version checks, and stable error parsing remain
outside model-authored presentation.
As a backend for static pages (MCP)
A published App can expose selected released queries and commands as MCP
tools. wf app mcp-access authorize <app-id> defaults to published queries
only. Use --tools <query,command,command> to opt in to writes, or the retained
--read <query> --write <command> pair. --expires <ISO-timestamp> optionally
limits the grant's lifetime. The account MCP tool manage_access mcp-grant/authorize and SDK
authorizeAppMcpAccess accept the same resources and expiresAt selection.
The result includes a token-free mcpUrl (https://wrapflow.com/mcp/apps/<app-id>)
and a wfm_ token shown once. Header-capable remote MCP clients use that URL
with Authorization: Bearer <token>; never put the token in the URL. The
endpoint supports initialize, tools/list, and tools/call. Tool schemas
pin the live compatibilityHash; writes require confirm: true and an
idempotency key. The grant cannot build or publish, reach another App, or
invoke an unselected operation. Revoke with wf app mcp-access revoke <app-id>.
For released scriptless hosted interfaces (manifest v5), the per-App HTTP MCP
also supports resources/list and resources/read. Granted tools link to a
release-specific ui:// resource through _meta.ui.resourceUri; the resource
returns the published interface as text/html;profile=mcp-app for an MCP Apps
host to render inline. The payload contains no injected account or grant token.
Its CSP denies scripts and all network access, including images and fonts.
This first slice is an interface preview: live actions still run through the
granted tools. UI RPC interactions and live updates are not connected in this
slice; rendering inside Claude.ai/ChatGPT remains unverified. External and
executable frontends keep their tools without an interface resource. The
public web App retains its embedding restrictions.
A direct HTTP client of POST https://wrapflow.com/mcp (Bearer token, JSON-RPC
tools/list) must set its own User-Agent. Python 3 urllib.request sends
Python-urllib/3.x when you leave the header unset. Cloudflare refuses that
signature with HTTP 403 error 1010 before Wrapflow sees the token, so the body
is Cloudflare's access-denied page rather than a Wrapflow error. Send a header
that names the client, such as User-Agent: my-script/1.0. The same call then
reaches Wrapflow. wf mcp over stdio does not make this HTTP call.
A grant follows the exact operation you chose, by its kind (query or command)
and its identity in the App's Contract, never by name. A query and a command
may share a Contract name; select them as <name>_query and
<name>_atomic_command (a plain <name> selects the query). A later release
can only remove tools from a grant, never add or widen one: if the release no
longer has that exact operation, or it became the other kind, the tool
disappears and inspect and tools/list report it in dropped ("dropped from
this grant because the operation changed"). Authorize again to grant the new
operation.
URL-only Claude/ChatGPT connector authorization is not provided by this bearer
endpoint; it has no server OAuth flow. The original stdio bridge remains:
point wf mcp at WRAPFLOW_APP_MCP_TOKEN to list the same selected tools.
wf mcp starts a zero-dependency MCP server over stdio, so a coding agent can
drive Wrapflow as the backend for whatever it is building. For a complete App,
use the typed App Contract lifecycle above. The lower-level tools below remain
for background app capability URLs (CORS is on for token-gated public endpoints):
publishwithpipelineId→ background app/f/view + OpenAPI discovery +latest.jsonbounded table URL (+ form submit URL). A ready metric-only view keeps OpenAPI and callable routes available whilelatest.jsonreturns 409public_view_no_table_result.publishwithappIduses the hosted App release authority instead.enable_data_intake→ POST JSON rows in (events, sign-ups, telemetry). The live intake needs an admin token. Withtarget: "draft"it prepares the draft instead: nothing is enabled and no token is mintedsend_test_events→ a draft token's way to exercise a Live stream app on its draft: validated sample events go to the draft only (never the live stream or the released app),read: truereads them back,replace: truestarts overenable_invoke_endpoint→ run the app per request and get its result table back in the response (respondWithResult)run_workflow→ run a background app now (draft or live). For apps with a Request step (source.request), pass optionalrequestobject ontarget: "draft"; published live Request apps are invoked via their invoke-v1 entrypoint. For apps with a Live stream step, passevents(sample rows, or"draft"for the saved test events) ontarget: "draft"configure_run_api_file_target→ with explicitly supervised owner/admin authority, persist the exact released File or Sample step used by authenticated Run API raw or multipart bodies. Generated App clients do not expose this owner control-plane setting.integration_guide→ the build order + copy-paste snippets
Register in an agent (e.g. Claude Code .mcp.json):
{ "mcpServers": { "wrapflow": { "command": "node", "args": ["./node_modules/wrapflow/wf.mjs", "mcp"] } } }Auth comes from wf login config, or WRAPFLOW_URL + WRAPFLOW_TOKEN env
vars. A plain wfh_… endpoint uses the URL token as its capability. Published
or invoke endpoints configured with BYO app-user auth additionally require
WRAPFLOW_APP_USER_TOKEN; protected async job status/cancel calls must use the
same app user that started the job.
Interface posture
The supported creation interfaces are a plain-English prompt (wf create "…"
or wf app build, the same AI path as the app) and the deterministic kernel
verbs (wf app create, wf create --empty, wf app attach, wf describe,
wf test, wf define) — never hand-authored graph JSON. The behavior's graph schema is
an internal, unstable detail: it is necessarily visible (the editor and the
export feature ship it), but it is not a contract. The hidden
--graph-json flag exists for our own tests and may change without notice.
Auth model
wf login exchanges email+password for a long-lived least-privilege API token
(read+run+draft) stored in ~/.config/wrapflow/config.json. It can build
and test drafts and edit App HTML, but can never release an app behavior,
publish App HTML, rotate credentials, or touch live records — releasing and deployment
stay yours. Human operators can deliberately run wf login --admin; the CLI
prints a high-authority warning, and wf mcp refuses that token unless the
single process explicitly sets WRAPFLOW_MCP_ALLOW_ADMIN=1. Environment-based
MCP configuration must declare opaque-token metadata with
WRAPFLOW_TOKEN_SCOPES=read,run,draft.
Passwords and
browser session cookies are never written there. wf stream uses the API
token to explicitly rotate a fresh intake secret; ordinary entrypoint reads
remain redacted.
The config directory is mode 0700 and the secret-bearing config file is
forced to mode 0600 on every write. A new login replaces all prior account
state, including saved stream aliases. Use the hidden prompt or pipe a password
with --password-stdin; --password is rejected so a password cannot be put
in shell history or process arguments by mistake.
wf run defaults to --target draft (or --target live explicitly). After changing a graph, run the
draft; wf release automatically uses the latest successful draft run (or pass --from-run <run-id>). Production refreshes
name --target live explicitly. When publishing a public view, choose which columns to expose with --columns <table>=<col,col> (or configure explicit column projection on the table). Pass --confirm-broad-tables only as a last resort (this makes every column of public).
Ask the owner for a service key
Request a key without passing its value through the agent:
wf secret request --name "Webhook key" --purpose "Post approved events" --host api.example.com --auth bearer --flow <flowId>
wf secret status <requestId>
wf secret attach <requestId> --flow <flowId> --step <stepId>
wf secret revoke <requestId>Show the returned Wrapflow URL to the person. Only the signed-in owner can enter
an API key or token there, or choose a matching saved connection. The link expires
in 30 minutes and accepts one successful submission. Status returns only
connectionId. wf secret attach binds that connection to one step of the Flow
(the step's connectionId) once the request is fulfilled; the agent passes ids only
and never sees the key. The step must call the host the person allowed. Reading the
Flow afterwards shows uses connection 'Webhook key' (set by the owner) on the step,
never the value. Never ask for an
account password or a key in chat. Authentication can also be apiKeyHeader
with --header-name X-Service-Key, or basic with owner-entered username:API-token.
The host is mandatory and cannot change after the request. --flow is optional.
The owner can inspect and revoke requests in the Flow's See inside view.
The SDK methods are requestSecret, secretStatus, attachSecret, and revokeSecret. Both
MCP transports expose manage_access secret/request, manage_access secret/status, manage_access secret/attach, and manage_access secret/revoke.
These accept request metadata or a request ID; they never accept the value.
Deploy a built UI from GitHub Actions
Your CI builds the UI; Wrapflow hosts it. An App created with --frontend
external takes your static build on its draft. The owner (signed in, or an
admin token; not the read+run+draft login token) creates a deploy token
for that one App:
wf app deploy-token create <app-id> [--name "GitHub Actions deploy"] [--expires-in-days 90]
wf app deploy-token list <app-id> # names, prefixes, last use; never the secret
wf app deploy-token revoke <app-id> <token-id> # its uploads then answer 401The secret is printed once. Store it as the repository secret WRAPFLOW_TOKEN
(and the base URL as the variable WRAPFLOW_URL). Then a job like this uploads
each build to the draft (add wrapflow to the project with the pinned install
shown at the top, and run the installed copy, not npx):
on: { push: { branches: [main] } }
jobs:
deploy-ui:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci && npm run build # your build; writes dist/
- run: node ./node_modules/wrapflow/wf.mjs app upload-ui --app <app-id> --dir dist
env: { WRAPFLOW_URL: "${{ vars.WRAPFLOW_URL }}", WRAPFLOW_TOKEN: "${{ secrets.WRAPFLOW_TOKEN }}" }- Draft only. The token uploads that App's UI to its draft and reads back the upload status, nothing else: it cannot release, read or change App data, reach other Apps, or read secrets. Releasing still needs the owner's approval, so a push never changes what people see on the live App.
- Source link. Each upload records where the build came from, and the owner
sees it when reviewing (
Source: linked to github.com/acme/portal @ a1b2c3d4e5f6inget_app_ui,bundle.sourceinwf app ui). In GitHub Actions the link comes fromGITHUB_SERVER_URL,GITHUB_REPOSITORYandGITHUB_SHA; elsewhere fromgit remote get-url originandgit rev-parse HEADwhen git is present. Pass--no-sourceto skip it. The API takessource: { repoUrl, commit }, an https address and a 7 to 40 character commit SHA. - Limits and expiry. An App has up to 5 live deploy tokens, separate from
your own API tokens, which have no count limit.
--expires-in-dayssets an end date; an expired token answers 401deploy_token_expired.
MCP exposes manage_access deploy-token/create, manage_access deploy-token/list and
manage_access deploy-token/revoke; the SDK has createAppDeployToken,
listAppDeployTokens and revokeAppDeployToken (REST: /api/apps/<app-id>/deploy-tokens).
Read a Space as one system
wf system [<space-id>]Returns the Space's tables, Apps (with their own backend), backends whose UI is
hosted elsewhere, agents and outside services, with one line per pair named
after what moves data along it or "n connections", health notes and groups of
things that work together. Defaults to the current Space; read-only. The SDK
method is getSystem(spaceId?) and MCP exposes get_system. When you host an
App's UI yourself, wf app integration ui-host <app-id> <https://your-ui…>
(--clear to remove; SDK setAppUiHost, MCP set_app_ui_host) records where,
so its Backend node reads "UI hosted by ".
Change a backend with the owner's approval
An agent proposes one additive change (entity, command or view, the same spec define takes; a scheduled pull
is defined on the draft and released, never proposed). Wrapflow
returns a plain-language summary and a one-hour owner link, and changes nothing until the signed-in owner approves.
wf change propose --flow <flowId> --kind entity --spec '{"name":"reservations","fields":[...,{"name":"phone","kind":"text"}]}'
wf change propose --flow <flowId> --changes '[{"kind":"entity","spec":{...}},{"kind":"command","spec":{...}}]'
wf change status <proposalId>
wf change history --flow <flowId>Adding fields, entities, commands and views is accepted. Renames, removals, type changes and tightened rules
are refused with the reason. Related changes, such as a field and the command that uses it, go in one proposal
as changes (up to 5, applied in order, one approval); MCP and the SDK take changes: [{ kind, spec }, ...].
The SDK methods are proposeChange, changeStatus, revokeChange and
changeHistory; MCP exposes propose_change, change_status, revoke_change and change_history.
History includes direct saves from define and apply, as well as approved proposals.
An entry with kind: "definition" carries revision (author, time and redacted diff)
and createdAt; an approval carries proposalId and approvedAt.
MCP Flow-target tools accept appId for an App with one Flow, or pipelineId
for an explicit Flow. test {appId} defaults to all saved scenarios;
test {appId, scenario: {spec: candidate}} runs those plus the candidate without saving it.
If the draft changes before approval, status returns stale with a message asking for a fresh proposal.
Propose the change again against the current draft; waiting on the old link cannot apply it.
MCP access operations use the single manage_access tool with
{kind, action, input}. Below, manage_access secret/request is shorthand for
manage_access {kind:"secret", action:"request", input:{...}}; every operation's
fields go inside input. Sign-in still needs owner approval; deploy tokens
still need owner/admin authority; test users remain draft-only.
Turn on sign-in for per-person rows
Entities with rows or access need verified hosted sign-in, which only the owner can turn on:
wf signin propose --flow <flowId> --signup anyone|invite|allowlist [--allow [email protected],@x.com]
wf signin status <proposalId>
wf signin revoke <proposalId>A proposal changes nothing. Show the returned URL to the owner; only they can
approve it while signed in. The link expires in one hour. After status reads
approved, wf advanced define <flowId> entity '{"name":"tasks","rows":"own",...}'
succeeds. The SDK methods are proposeSignIn, signInStatus and revokeSignIn;
MCP exposes manage_access signin/propose, manage_access signin/status and manage_access signin/revoke.
SDK proposeSignIn and MCP manage_access signin/propose accept exactly one of appId or
pipelineId. MCP describe accepts the same targets. An App ID resolves only
when the App has one Flow; with multiple Flows, pass a pipelineId explicitly.
For a fresh create_app result, call define with appId first, then use the
pipelineId returned by define. Describing or proposing sign-in creates no Flow.
Once sign-in is on, a table's access lists who may see a row: "creator",
"signed-in", {"person":"<text field>"} (the field holds the person's App-user
id), {"email":"<email field>"}, {"role":["<role>"]} and
{"members":"<membership table>","via":"<reference field>"} for organisations
and queues (the membership table declares "membership":{"person":…,"group":…}). A person sees a row when any one holds; the
server applies it to queries, views, counts, live updates, references and
files. wf advanced describe <flowId> entity shows each table's access and
its whoSees sentence:
wf advanced define <flowId> entity '{"name":"orders","access":{"read":["creator",{"person":"seller"}]},"fields":[...]}'Changing another person's row needs a patch or delete command that declares
edits with the same relations plus "readers" (anyone who may see the row);
without it a command is the row creator's, whatever its name:
wf advanced define <flowId> command '{"name":"reassign","entity":"tickets","operation":"patch","fields":[{"name":"assignee","kind":"text"}],"edits":["creator",{"role":["lead"]}]}'Sharing and binding tables across Apps (Tier 5 shared inventory)
An App-owned table can be bound by another App by reference (table: "app-entity-v1:<appId>:<name>" or <appId>/<name>), without copying rows. When two distinct Apps (for example a warehouse admin app and a customer storefront) need to read or mutate the same table:
# In App B: bind App A's inventory table by reference without copying rows
wf advanced define <flow-or-app-id> entity '{"name":"inventory","table":"app-entity-v1:<app-a-id>:inventory","fields":[{"name":"sku","kind":"text"},{"name":"stock_level","kind":"number"}]}'
# Define local orders entity referencing inventory
wf advanced define <flow-or-app-id> entity '{"name":"orders","fields":[{"name":"product","kind":"reference","entity":"inventory","required":true},{"name":"qty","kind":"number","required":true,"min":1}]}'
# Define atomic take command that deducts stock from App A's table
wf advanced define <flow-or-app-id> command '{"name":"place-order","entity":"orders","guards":[{"source":"inventory","where":[{"field":"_wf_record_id","op":"eq","valueField":"product"}],"column":"stock_level","take":{"amountField":"qty"},"message":"Not enough stock for this order."}]}'App B reads and queries App A's live rows directly, and place-order commands in App B atomically deduct from App A's inventory under the write coordinator. If the order write fails or is rejected, the taken stock is automatically given back.
Creating the account itself
wf login signs in; it does not register. There is no CLI verb for creating an
account, and there is no POST /api/auth/signup route — an unmatched /api/*
path falls through to the generic authentication guard, which answers 401
"Sign in to use Wrapflow." even though nothing about your credentials is wrong.
The real route is POST /api/auth/register:
# Session mutations are CSRF-guarded: send the exact Origin this deployment is
# configured with. GET /api/health is unauthenticated and publishes it as
# `expectedOrigin`, so ask rather than guess — it differs from the address you
# dial behind a proxy, tunnel, port-forward, or custom domain.
curl -sS -c jar.txt -X POST "$WRAPFLOW_URL/api/auth/register" \
-H "Origin: $WRAPFLOW_ORIGIN" -H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"…"}'Registration returns an HttpOnly wrapflow_session cookie. For ordinary CLI
use you do not need to handle it: run wf login with the same email and
password and it performs the whole exchange (POST /api/auth/login, then
POST /api/tokens for a read+run+draft token, then POST /api/auth/logout
to drop the session). Keep the cookie only when driving the HTTP API directly,
and send it on every session-authenticated call.
docs/PROGRAMMATIC-APP-AUTHORING.md in the Wrapflow repository is the
reference for this whole surface: registration, the Origin/CSRF guard and its
403 "Refresh Wrapflow and try again.", token scopes, free-plan limits and
quotas, and the exact HTTP routes behind every command in this README. Read it
before hand-rolling requests against the API. Product-using agents building
Apps through this CLI should follow this README and
docs/BUILD-WITH-WRAPFLOW-AS-AN-AGENT.md; neither is permission to change
Wrapflow source.
Schedules
Draft tokens use wf schedule <id> --target draft --cron "0 21 * * *"
to declare a schedule. With --target draft, omit cron to read it, use
--disable to clear it, or --test-fire to run it once against draft data.
The SDK uses draftSchedule(id, {cron, timeZone}), draftSchedule(id) and
draftSchedule(id, {clear:true}); test with run(id, {target:'draft', schedule:'draft'}).
MCP schedule accepts target:"draft" and testFire:true. Draft schedules
never tick unattended. Tests read their own App preview collections and draft
stream events; emails, HTTP outputs and cross-App commands are held. A Flow
without an App cannot read live collections in a token draft run. Saved
production datasets cannot be read or queried by token draft runs.
wf schedule <id> --cron "0 2 * * *" [--tz UTC] sets a Flow's recurring
schedule, wf schedule <id> reads it, and wf schedule <id> --disable clears
it. The SDK uses schedule(id, { cron, timeZone, enabled }) and
entrypoints(id); the MCP tool is schedule (pipelineId, then cron and
optional timeZone to set, disable: true to clear, neither to read). All three
use the existing entrypoints API and return the schedule as the server saved it.
Enabling a schedule requires a released Flow; setting or clearing it requires
admin permission. Reading and clearing a previously stored draft schedule do
not require a release. After creating a background app (e.g. via
wf create or the MCP create_workflow tool for a daily reminder or periodic job),
verify and release the Flow, then call schedule with the returned id/pipelineId
to configure its recurring schedule.
