* docs: apply Vercel technical writing standards Audit the complete documentation corpus, package READMEs, skills, and source TSDoc/comments against the vercel-technical-writing skill and style-rules.md. Normalize sentence-case headings without changing published anchors, remove prose em dashes and filler wording, improve active voice and self-contained phrasing, standardize product/brand capitalization, American English, list punctuation, units, and code fence languages, and preserve exact runtime strings/table placeholders. All executable code is unchanged. Modified skills have their metadata versions bumped. * docs: extend writing audit to repository Markdown Apply the same technical-writing rules to design documents, compiler specifications, workbench guides, package changelogs, and the remaining tracked Markdown outside the deployed docs corpus. Preserve historical meaning, commands, output literals, table placeholders, and heading anchors. * docs: exclude generated package changelogs from audit
Python workbench app
A Python implementation of the workflow app side, so packages/core/e2e/e2e.test.ts
has something other than JavaScript to run against. The test driver stays in
TypeScript as the source of truth for the protocol, and this app is the
second implementation it drives.
Built on vercel-py, pinned by commit in
pyproject.toml: one [tool.uv.sources] entry for the umbrella vercel
package. The only code this app imports is vercel.workflow, which since
vercel-py#299 is its own vercel-workflow distribution. However, depending on that
name directly puts @vercel/python in workers mode and silently unsubscribes
the deployment from the queue, so the umbrella stays until the builder learns
the split. The note above the dependency has the detail. Everything but
vercel itself therefore resolves from PyPI, the runtime
included; the note above the source entry says how to check that what you got
matches the rev you asked for. Bump the rev deliberately, and rerun the suite
when you do.
Running it
cd workbench/python
uv sync --locked
WORKFLOW_PUBLIC_MANIFEST=1 pnpm dev # uvicorn on :3000
Both of those guard the lock against your personal ~/.config/uv/uv.toml
(--locked refuses to rewrite it, and pnpm dev passes --no-config to
uv run, which re-locks on startup otherwise). If you find an [options] block
at the top of uv.lock, something ran uv without one of them and CI will reject
the result. The note above [tool.uv.sources] in pyproject.toml has the rest,
including how to bump the pin.
Then, from the repo root:
DEPLOYMENT_URL="http://localhost:3000" APP_NAME="python" \
pnpm vitest run packages/core/e2e/e2e.test.ts
Both sides talk to the same world-local data directory
(WORKFLOW_LOCAL_DATA_DIR, defaulting to .workflow-data here) with
byte-compatible file formats, so the TypeScript CLI can inspect runs the Python
app produced:
cd workbench/python && node ./node_modules/workflow/bin/run.js inspect --json runs
Running it against the Vercel world
Both sides are wired: the workbench-python-workflow project is rooted at
workbench/python and Git-connected, and the e2e-vercel-prod matrix carries a
python row (excluded from the quickjs VM axis, which is a JS-engine dimension
with no Python meaning). What makes the build work:
-
vercel.jsondeclarespyproject.tomlas the build src. That is what puts@vercel/pythonin "declared-only" mode. Without it, the[tool.vercel]keys are ignored because the builder only attaches workflows to recognized Python frameworks or to declared builds, and a bare ASGI app is neither. -
That
usecarries an explicit builder version, which the other workbench projects do not need. An unpinneduseresolves to whatever@vercel/pythonships inside the Vercel CLI the build platform happens to run, and that lags npm. Builds were picking up 6.53.0 (CLI 58.1.0) well after 6.55.x was out. 6.53.0 predates queue mode, so the builder silently fell back to "workers" mode and pointed the workflow function straight atapp:registry. That is aWorkflowsobject, not an ASGI callable, so every queue delivery 500'd withCould not determine the application interface for 'app:registry'. The run was created, the message was delivered, and nothing ever executed. Everything below about consumer groups depends on queue mode, so the pin is what makes it reachable at all. Bump it deliberately. -
[tool.vercel] entrypoint = "app:app"builds the web function. On Vercel it serves exactly one useful route,manifest.json. Runs arrive over the queue, so the hand-writtenPOST /flowadapter is dead code there. It is how the local world delivers, and only that. -
[[tool.vercel.workflows]] entrypoint = "app:registry"builds the workflow function. At build time the builder importsapp, readsvercel.queue.get_subscriptions(), and writes onequeue/v2betatrigger per subscription. You can run that introspection yourself, exactly as the builder does:VERCEL=1 VERCEL_REGION=iad1 VERCEL_DEPLOYMENT_ID=dpl_introspection \ uv run python -c 'import importlib; importlib.import_module("app") from vercel.queue import get_subscriptions print([(s.topic, s.consumer_group) for s in get_subscriptions()])' # [('__wkf_workflow_*', 'default')]One topic, not two: since vercel-py #251 a step invocation rides the workflow topic as a
stepIdon the invoke payload, the way the TypeScript SDK does it.defaultis the point. It is the consumer groupcreateWorkflowQueueTriggerwrites for the TypeScript SDK on the same topics, which is what makes the platform deliver to a Python consumer at all. Getting there needed vercel/vercel#17236 (@vercel/python6.54.0), which replaced a consumer name derived from the output path with the introspected one.Reaching queue mode at all has a second, blunter condition: the builder looks for the literal name
vercelin[project].dependenciesand then readsimportlib.metadata.version("vercel"), requiring >= 0.8.0. Both halves name the umbrella distribution, so declaring onlyvercel-workflow(the package that actually holds the runtime since vercel-py#299) drops the build back to workers mode. That failure is quieter than the 6.53.0 one above: the trigger is written for consumerapp_registryon__wkf_*rather thandefaulton__wkf_workflow_*, so deliveries do not 500, they never arrive. Every run times out with zero requests logged against it. No released builder knows the new name (checked through 6.56.2).
.python-version pins 3.14 so the deployed interpreter matches the local venv;
the builder would otherwise default to 3.12.
The project also needs a VERCEL_WORKFLOW_SERVER_URL env var scoped to
Preview, with the same value every other workbench project has. On a PR the
e2e-vercel-prod job points the driver at a branch workflow-server
(tests.yml:484); without the matching variable on the project the deployed app
keeps writing to production vercel-workflow.com, and the two sides end up on
different primary stores. vercel-py reads it in
vercel/workflow/_internal/worlds/vercel.py. Production runs need nothing: the secret
resolves to '' on main, so both sides use vercel-workflow.com.
That variable is about the app reaching the right store, and pointing it at the
branch workflow-server (e2e.vercel-workflow.com) means clearing that server's
deployment protection. Two callers need to, and they need it differently.
The driver clears it with the workbench project's own identity, so
workbench-python-workflow had to be listed in that project's Trusted
Sources alongside the JS workbench projects. It now is: driver writes land, and
runs are created. Before that, every write failed with v4 createEvent: response missing required x-wf-* headers and a SyntaxError: Unexpected token '<'.
The response was an HTML SSO page, not a workflow-server response.
The app clears it with a header, and until vercel-py#278 vercel-py did not
send that header. getHttpConfig (packages/world-vercel/src/utils.ts:366) sets
both Authorization: Bearer <oidc> and
x-vercel-trusted-oidc-idp-token: <oidc>; vercel-py's _cbor_request set only
the first. Trusted Sources reads the second, so the first call the workflow
handler made (runs_get, before any replay) came back 302 to the SSO page,
and every delivery 500'd:
HTTP Request: GET https://e2e.vercel-workflow.com/api/v2/runs/wrun_... "HTTP/1.1 302 Found"
File ".../worlds/vercel.py", line 219, in _cbor_request
result = resp.json()
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
cbor2.CBORDecodeEOF: premature end of stream
That the redirect surfaced as a CBOR decode error rather than as "you were
redirected to a login page" was a second bug in the same function: it parsed the
body before checking the status. Both are fixed in the pinned rev. The auth
headers now split proxy from direct the way getHttpConfig does, and a non-2xx
derives its error from the status before the body is touched.
Production runs are unaffected on both counts: the secret is '' on main, so
each side talks to vercel-workflow.com, which is not protected.
CI reaches this deployment past deployment protection through the project's
trustedSources.oidcProviders entry for token.actions.githubusercontent.com.
A trustedSources.projects entry would additionally let a locally pulled
VERCEL_OIDC_TOKEN in; the other workbench projects have one, this project does
not need it for CI.
The divergences that cause failures are cataloged in vercel-py's queue notes: region routing, no delivery cap, and a one-second floor on immediate re-enqueues.
The one that used to block this lane outright was the queue transport. Once
queue mode was reached, every delivery 500'd on UnicodeDecodeError: 'utf-8' codec can't decode byte 0xb9 → MessageCorruptedError. 0xb9 is CBOR:
@workflow/world-vercel publishes the invoke payload with CborTransport
(packages/world-vercel/src/queue.ts:34) whenever the run's specVersion >= 3,
and the run's spec version comes from whoever created it. Here that is the
TypeScript driver, stamping 5 from world-vercel's own specVersion, so
Python's declared 2 never enters the decision. vercel-py had only a JSON
consumer path. Nothing in this repo could override either side without pinning
the lane to a legacy spec version, which would have made the one lane testing
the current protocol the one lane not testing it.
vercel-py fixed it in two parts, spanning two packages: vercel-py#265 attaches
a CBOR-with-JSON-fallback transport to the workflow topic, and vercel-py#266
taught Topic / TopicPattern to carry a codec in the first place. That is why
the pin briefly needed a second [tool.uv.sources] entry. vercel-queue 0.8.0
carries #266, so PyPI is enough again. Attaching it to the subscription rather than to a
client is what makes it work when deployed. The function that receives a push
delivery is the builder-generated _vc_queue_handlers/<name>.py, whose
vercel.queue.asgi_app() constructs its own QueueClient with no arguments, so
a transport set on the world's client would never have been consulted.
Confirmed fixed against a real deployment: on 36d5763a the invoke payload
decodes and the workflow handler runs, which is how the protection-header gap
above became visible at all. It is the next call the handler makes.
world-local was never affected. Python is producer and consumer there, so
JSON on both ends is self-consistent.
Behind that sat one more, and it is why cryptography has to be installed. On
Vercel the run input is
written by the driver, and @workflow/world-vercel encrypts every payload it
can resolve a per-run key for, which on a deployment is all of them, with no
opt-out env var or config flag. Each run therefore arrived as an encr
envelope and died at the first hydration of its input, before any fixture code
ran:
SerializationError: the input of run wrun_... uses the 'encr' format,
which this SDK cannot read
vercel-py added read support in vercel-py#279: HKDF-SHA256 over the
deployment secret (32 zero salt bytes, info = "<projectId>|<runId>") to
derive the per-run key, then AES-256-GCM over [nonce 12][ciphertext+tag].
The plaintext carries its own format prefix, so it re-enters hydrate as the
devl payload it would have been. AES-GCM comes from cryptography, which
vercel-workflow now requires outright; while the runtime lived in vercel it
sat behind an encryption extra this app had to remember to ask for, and
forgetting it produced the same failure with a message naming what to install.
Reading is all this app needs. Python still writes plain devl, and the
TypeScript reader passes non-encr payloads through untouched
(maybeDecrypt, packages/core/src/serialization/encryption.ts:284), so the
two sides interoperate without Python ever encrypting anything. encp, the
X25519 sealed-box format one run uses to write to another, became readable in
vercel-py#297; nothing in the suite produces one either way.
Again, world-local is exempt. It has no deployment key to derive from, so
the local lane could never have caught this. It is the clearest case so far of
why the Vercel lane earns its keep.
The last one arrived from this repo rather than being found here. #3389 gave
world-vercel specVersion: 6 (slot-numbered event IDs), so every run the
driver creates is stamped 6, while vercel-py typed the field as
Literal[1, 2, 3, 4, 5] on the shared event base. The write itself succeeded.
Parsing the 200 response raised. vercel-py now splits the two meanings the
way TypeScript does: SPEC_VERSION_CURRENT stays 2 (what Python stamps) and
SPEC_VERSION_MAX_SUPPORTED is 6 (what Python will read), which is what keeps
version 7 from being the same outage. world-local is on 5, so once more only
the deployed lane saw it.
Slot IDs themselves need nothing from Python: a slot body is 26 zero-padded
decimal digits, which every ULID validator already accepts and which sorts the
same way. The one thing that breaks is decoding a timestamp out of an event id,
and vercel-py never does. It mints IDs only for world-local.
The last one only ever showed up here, and it read as flakiness for two rounds
before it read as a bug: one arbitrary run per suite died with WorkflowWorldError: workflow run wrun_… not found about 400ms after start(), the queue did not
redeliver, and the driver sat out its 60-second timeout. A different fixture each
time, which is what made it look random. It is not: start()
(packages/core/src/runtime/start.ts:577) issues run_created and the queue push
in parallel, deliberately. "If events.create fails with 429/5xx, the run was
still accepted via the queue," so a consumer that finds no run row has found a
normal state, not an error one. The TypeScript consumer never reads the row at
all: it writes run_started, which is idempotent and creates the run when
run_created was never seen, and takes the run entity out of that response.
vercel-py had it the other way round (runs_get, then run_started only if
status == "pending") and 500'd on the 404, and it dropped the runInput the
message carries for exactly this purpose: input, deployment ID, workflow name,
spec version, attribute seed. vercel-py#282 carries runInput through,
#284 bootstraps from run_started, and #283 gives world-local the same
resilient start. This also retired the app's last unsupported entry,
the deterministic version of the same defect: resilient start: addTenWorkflow
completes when run_created returns 500 deletes the row on purpose.
world-local did have the race, and could never lose it: both writes are local
files in one process while the queue delivery takes an HTTP round trip, so
run_created always won.
Conformance baseline
What the suite runs here is declared in e2e-conformance.json: the ported
fixtures and, when there is one, an unsupported map naming individual tests
whose failure is a runtime gap rather than a missing fixture. There is none right
now. Both axes are ratchets: a claim that stops being true fails the run instead
of quietly skipping, so growing the file is the only way to move.
ConformanceConfig in packages/core/e2e/utils.ts spells out each direction.
Current baseline: 9 passing, 128 skipped, of 137 on world-local, and
8 of 156 on Vercel. It is one baseline, not two. The extra 19 collected on
Vercel are e2e-agent.test.ts, which that lane also picks up and skips whole,
and the ninth pass is deploymentId: 'latest' is a no-op in non-Vercel worlds,
which is local by definition.
What is missing
This app is honest about being early. In rough order of how much it costs:
- Most fixtures are not ported yet. This includes 66 tests across 52 fixtures.
They are not blocked on one thing anymore: the largest blocks are hooks (19
tests, where vercel-py's
BaseHook.wait()has a different shape than the async-iterable hook the fixtures use), streams (11, where vercel-py now hasread_stream/get_writableand nothing here uses them yet),setAttributes(9, no Python equivalent), andFatalError/RetryableError(7;FatalErroris exported now, andRetryableErrorhas no Python counterpart at all). - The
.well-known/workflow/v1surface lives inapp.py, not the SDK, and reaching it needs threevercel.workflow._internalimports (workflow_entrypoint,FLOW_ROUTE, and theHTTPRequestbase), none of which has a public equivalent. The module docstring explains why that surface belongs in the app. - The health-check tests stay JS-only, but no longer for want of an
implementation. vercel-py answers both probes as of
vercel-py#292: the?__healthone insideworkflow_entrypoint(soapp.pyonly routes to it) and the queue-based one inworkflow_handler. What the driver additionally asserts is aworkflowCoreVersionstring, which Python deliberately omits because it names a JavaScript package's version and the reader feeds it to that package's capability tables. Moving the three tests across means deciding what a non-JS SDK should report there. - No webhook route and no app-specific API routes, so the webhook and direct-step-call tests are JS-only too.
Two things in workflows/99_e2e.py look like mistakes but are intentional: a module
name starting with a digit, and Python functions in camelCase. Its docstring
says why both are load-bearing; don't "fix" either without reading it.