Test and publish
Draft, dry run, review, publish and run live — and why what runs is a frozen version.
A handler's code goes through a short lifecycle before anything runs it for real. Each step answers a different question: does it save (is it allowed code), does it work (dry run), may it ship (review), and does it behave in production (run live).
Where handlers live
In the console, open the app and go to Code › Handlers. Create Handler asks for a name and opens the editor. The editor autocompletes the SDK from the same contract the SDK reference is generated from, and its Examples tab has a worked example for each area of the SDK.
Write with AI (or Change with AI on existing code) drafts a handler from a description, dry-runs it, and corrects it until it passes. The result lands in the editor; AI never saves or publishes anything on its own.
A handler's name is how everything reaches it, so it must be unique among the app's live
handlers. Two live handlers with one name both refuse to run (HANDLER_NAME_AMBIGUOUS).
Saving: refused code never gets stored
Every save scans the code and refuses it, with the reason, when it:
- uses an elevated SDK power you do not hold:
SDK_CAP_NOT_GRANTED(403), listing each missing capability. See Capabilities & governance; - passes anything but a static string to
sdk.query:HANDLER_UNSAFE_RAW_SQL(400). See Raw SQL safely; - addresses an external tool server dynamically (
sdk.mcp[name]), names a tool the app does not have, or calls one inside a transaction.
The editor shows the same checks as you type: an Elevated SDK powers used panel with a mark for each power you hold or lack, and a warning under any call you could not save.
Dry run
Run › Dry run runs the code in your editor, saved or not, in the same Deno sandbox a real run uses, against your app's real data, inside a transaction that is always rolled back.
- Writes happen and are undone. The handler reads its own writes, so multi-step logic behaves as it will live; nothing persists.
- Outside calls are recorded, not made. An email is checked as far as it can be (the message,
the app's email grant, the sender, every placeholder) and then not sent. Stripe and external tool
calls are recorded with their arguments.
sdk.aimakes no provider call and spends nothing. - The report says whether the run would succeed, lists every operation it prepared, and shows the return value.
What a dry run does not tell you, on purpose:
- Whether a real caller would be allowed. Per-call authorization is skipped in a dry run. Use run live, below, to prove permissions.
- Anything after a stubbed response. Code that reads an email's or a tool call's result gets a placeholder. If the run fails because of that, the report marks it as unverifiable rather than failed.
Two rules make the result honest:
- A handler that catches a platform error and returns anyway fails the dry run, with the error it hid. A green result has to mean it worked.
- Schema changes never run in a dry run. DDL commits on its own and cannot be rolled back, so
it is refused with
DDL_NOT_DRY_RUNNABLE(422).
To dry-run code at all you must hold every capability it uses, exactly as at save.
Review and publish
What runs is a published version. A version moves through these states:
| State | Meaning |
|---|---|
draft | Being edited. Nothing runs it. |
review_pending | Submitted for review (Submit for Review). |
approved | Approved (Approve). The approver is checked against every power the code uses. |
published | Live (Publish). Its code and its capability manifest are frozen. |
rejected | Turned down in review. |
archived | The handler was archived. |
Only an approved version can be published. Publishing freezes the manifest: the list of elevated powers that version may use, as detected in its code. From then on the runtime enforces that list, not whatever the author or the endpoint holds later. So:
- granting a capability does not change a version already published. Publish a new version to pick
it up (until then the call fails with
SDK_CAP_NOT_IN_MANIFEST); - revoking a capability from an author does not quietly break code already in production.
Versions on the handler's page lists each version, its state, and which powers it carries.
Run live
Run › Run live runs the published version for real: rows it writes, emails it sends and outside calls it makes are permanent. The panel asks you to confirm that before it runs.
- Choose who it runs as. Your console account is not a principal of the app, so you pick one of
the app's principals: a user (and the organization to act in), an agent, or one of your scheduled
jobs, which then acts in that job's organizations as the job would. Without a choice it is
refused with
HANDLER_RUN_NO_PRINCIPAL. Running as someone else is recorded in the app's audit trail. - It takes the path a real request takes. The chosen principal must hold the endpoint's action, and the SDK calls inside are authorized by the endpoint's allowed actions and the version's frozen manifest. A job-only handler runs on the chosen principal's own roles instead.
- An unassigned handler has no envelope. A handler no endpoint fronts runs live with no allowed actions, so its data calls are refused until you assign it to an endpoint. The result says so.
Run live is how you prove the thing a dry run cannot: that this principal, through this endpoint, is allowed to do what the handler does.
Handlers from a repository
An app can take its handlers from a connected repository: every push to the branch deploys them
(see Deploy from a repository). A handler the repository manages cannot be
changed in the console (DEFINITION_CODE_MANAGED), and changes the AI proposes for it arrive as a
pull request. The folder layout and its authoric.json are in The bundle.