Functions, secrets, jobs, push, app repos
functions
Section titled “functions”Node 22 runtime. Must export default async (req: Request) => Response.
Built-in helper: import { auth, db, adminDb, email, ai, assets, push, repo, repos, getRoutedPaymentContext } from '@run402/functions'
auth.user()—Actor | null; taints cache bypass.Actor={ id, projectId, sessionId, email, emailVerified, authTime, amr, amrTimes };id, notuserId. Hallucinated names (getUser,getSession,currentUser,getServerSession,auth.protect,auth.signIn,auth.logout, …) throwR402_AUTH_UNKNOWN_EXPORTand failrun402 doctordeploy scan.auth.requireUser()—Actor. 303 →/auth/sign-in?returnTo=(HTML) or 401 envelope (JSON) on anonymous. Don’t catch — the platform handles redirect-vs-envelope automatically.auth.requireRole<const R>(role: R)/auth.requireMembership<const M>(m: M)— gate helpers; implyrequireUser; return{ user, role }/{ user, membership }. Always read fresh server-side grant state (no positive cache, so revocation is instant across tasks).auth.requireFresh({ maxAge, amr? })— per-AMR step-up. ReadsActor.amrTimes; a recent password proof does NOT satisfy{amr: ["passkey"]}.auth.fetch(input, init?)— same-origin-only fetch with synchronous URL validation;redirect: "manual"default; never forwards cookies or actor headers across origin hops.auth.csrfToken()/auth.csrfField()— double-submit token for hosted forms (renders<input type="hidden" name="_csrf" value="...">).auth.sessions.createResponseFromIdentity({ provider, subject, proof, amr, createUser? })— custom identity proof bridge. Platform verifies the proof end-to-end; raw-userId session minting is NOT in the public API.auth.sessions.endResponse()— sign-out: revokes the active session row + returns Set-Cookie clear.auth.grants.list()/auth.grants.revoke(id)(@run402/functions4.7+) — the signed-in person’s connected AI assistants (MCP clients that went through the app’s OAuth consent).list()→[{ id, client_id, client_name, verified_host, created_at, last_used_at, expires_at, scopes }](verified_host: nullmeansclient_nameis self-declared: label it unverified);revoke(id)→{ revoked }, false for an id that is not a live grant of this person. Only the caller’s own grants in this project;AuthRequiredErrorwhen anonymous. A revoked client’s next MCP call answers 401 withWWW-Authenticate. No page needed: link to the hosted/_run402/account/connections.auth.identities.link({ provider, subject, proof })— atomic nonce consumption + identity INSERT; 409 R402_AUTH_IDENTITY_LINK_CONFLICT on duplicate.db(req?)— caller-context DB client. In SSR with verified actor, mints 60s actor JWT (sub,project_id,session_id,authz_version) sorun402.current_user_id()works in RLS. Routes/rest/v1/*. Default choice; RLS handles current-user filters..eq("user_id", user.id)deploy-fails withR402_AUTH_REDUNDANT_USER_FILTERunless annotated// run402-allow-user-filter:.adminDb()— BYPASSRLS usingservice_key; routes/projects/v1/:project_id/rest/*, andadminDb().sql()routes/projects/v1/:project_id/sql(gateway rejectsrole=service_roleon/rest/v1/*). In a handler that reads the current user,run402 doctorflags it (SERVICE_KEY_IN_USER_REQUEST): usedb(req)there. Use only when function acts as platform: audit logs, webhooks, cron cleanup.getUserId(req)/getRole(req)— function-level gate header readers (x-run402-user-id/x-run402-user-role), distinct fromauth.*cookie sessions. Not exported: importing either from@run402/functionsthrowsR402_AUTH_UNKNOWN_EXPORT. Useauth.*or read headers manually.getRoutedPaymentContext(req)(@run402/functions3.7+) — confirmed x402 payment context for priced routed function requests. Returns{ scheme, paymentId, amountUsdMicros, amountMsat?, payer, network, asset, payTo, transaction, settledAt }ornull; key app-side idempotency bypayment.paymentId. A Lightning payment (networklnbtc:…,@run402/functions4.10+) carriesamountMsat, the invoice amount in millisatoshis, and anullpayer.email.send(opts)— send email from the project’s mailbox (see email section below);opts.bcc(one address or up to 5) adds blind copies in raw and template mode, each counted as one send, never named in any headerai.generateImage({ prompt, aspect? })— live image generation from deployed functions using project billing authority, not local wallet/x402 signing. Aspects:square,landscape,portrait; result:{ image, content_type, aspect }. Add app auth/rate limits before calling it from public routed functions.assets.put(key, source, opts?)— upload bytes to the project’s asset store from inside a deployed function. (opts.contentTypeis a JS option and stays camelCase; every WIRE field — manifests, plan/commit bodies — is snake_case:content_type.) Uses the same CAS substrate as deploy-time assets.sourceis a string,Uint8Array, or{ content | bytes }object. Options:contentType,visibility("public"|"private", default"public"),immutable(defaulttrue). Returns anAssetRefwithurl,immutableUrl,cdnUrl,sha256,size_bytes, etc. (camelCase aliases included). Use for user-uploaded content, generated images, runtime-produced files.assets.list(opts?)— page through the project’s blobs (prefix,limit,cursor,sort,filter). Each row carries the URL fieldsputreturned (url,cdn_url,immutable_url,cdn_immutable_url, and for encoded imagesdisplay_urlandvariants.{thumb,medium,large}), so a function can render a media grid; public URLs arenullfor private keys. Returns{ blobs, next_cursor }.assets.delete(key)— remove a key with the service key; revokes its immutable and variant URLs, queues a CDN invalidation for a public key, and revokes the key’s unexpired signed URLs at the edge (they answer 403 within a minute). Returns{ deleted, key, revoked_immutable_refs, cache_invalidation, signed_url_revocation }; a missing key throws (404). Needs@run402/functions4.8.assets.sign(key, { variant?, ttl_seconds? })— a time-boxed signed GET URL for a key, the way to deliver a private key (itsAssetRefURLs arenull).variant(thumb|medium|large|display_jpeg) signs that encoded variant instead of the original, so a members-only page serves the pre-encoded WebP ladder; for a video uploaded throughassets.createUpload(key, { video: { transcript: true } }),video_web|video_poster|video_transcriptsign the web copy, poster and timed transcript run402 prepared (listed inAssetRef.video);ttl_seconds60–604800, default 3600. Returns{ signed_url, expires_at, expires_in, variant? }; a key without that variant throws (404RESOURCE_NOT_FOUND).signed_urlis a CloudFront link signed for that one object: use it as is. Any origin can read it (Access-Control-Allow-Origin: *, so a WebGL texture or canvas can use a private photo), it answersRangewith 206, members viewing the same key share the edge cache, and it answers 403 afterexpires_ator within a minute of the key being deleted or overwritten.assets.signMany(items, { ttl_seconds? })— many signed URLs in one request (POST /storage/v1/blobs/sign):itemsis 1 to 1000{ key, variant? }. Returns{ items, expires_at, expires_in }, the items in order, each{ key, variant?, signed_url }or{ key, variant?, error: { code, message, details } }(RESOURCE_NOT_FOUND) for a key or variant the project lacks, so one stale row does not fail a page. Use it for a srcset or a gallery of private photos;signAssetRefs(refs, assets.signMany)from@run402/astro/manifestsigns whole AssetRefs with it.assets.fromRef(raw)— local rehydrate stored AssetRef JSONB into typed shape with camelCase aliases + variants. Store fullAssetReffromr.assets.putin JSONB; variant SHAs/immutable URLs cannot be re-derived from(source_sha,key). Tolerates partial legacy inputs; throws only on null/undefined/non-object.push.send({ title, body?, url?, tag?, to? })— queue a web push notification to the phones signed up to the project (to: { user_id }for one user’s phones; omit to reach every phone).title1–100 characters,body≤ 1000,tag≤ 64 (a newer message with the same tag replaces the older one on the phone),urla path on the app (default/). Returns{ message_id, status: "queued" | "no_subscribers", devices }(202 / 200). ThrowsRun402PushErrorwith the gatewaycode:VALIDATION_FAILED,QUOTA_EXCEEDED(403, the organization’s daily delivery cap:details.resource: "push_deliveries_per_day",used,limit,resets_at),RATE_LIMITED(429), andPUSH_UNSUPPORTEDon Run402 Core. Phones sign up from the app’s page through/_run402/push.js(see Push notifications); check delivery withrun402 push status.repo— the project’sdefaultapp repo (a plain, unencrypted git repository the app owns; not a KyGit vault).read(path)/readJson(path)/readBytes(path)(nullwhen absent),list(dir?),commit(files, { message?, author?, parent? })(a string is text, aUint8Arraybytes,nulldeletes, any other value canonical JSON; lands on a newer head unless one of its paths changed, else409 APP_REPO_CONFLICTwithdetails.conflicting_paths;{ committed: false }when nothing changed),revert(commit),repo.log({ path?, limit?, cursor? }),diff(from, to),at(commit),head(). One request reads one commit and reads its own writes. The author is the signed-in end user (synthetic address, verifiedRun402-Actor: <user_id>trailer), the committer the app. Files are at most 4 MiB (APP_REPO_FILE_TOO_LARGE: put media inassets.put, commit the reference). ThrowsR402AppRepoError;APP_REPOS_UNSUPPORTEDon Run402 Core. Guide: App repos.repo.createToken({ access, ttlSeconds? })/revokeToken(tokenId)— a credential for that one repo,readorwrite, for 60 to 86400 seconds: the git password and a Bearer on its read and commit routes, and no credential anywhere else.tokenis shown only in the answer;repos.open(name)handles have them too.repos— the project’s other app repos:repos.open(name)(a handle likerepo; no call),repos.create(name, { from? }),repos.list(),repos.delete(name, { confirm: name }). A name that does not exist isAPP_REPO_NOT_FOUNDwith acreate_app_reponext action; nothing creates a repo implicitly. Not the CLI’srun402 repos(vaults).snapshots.*(@run402/functions4.5+) — the function’s OWN project’s restore points, authorized by its service key (no project id or credential argument).snapshots.create({ label?, metadata? }),snapshots.list({ limit?, after?, kind? }),snapshots.get(id),snapshots.delete(id)(manual snapshots only),snapshots.restorePlan(id, { release? }),snapshots.restore(id, confirm, { release? }),snapshots.getRestore(snapshotId, restoreId). Labels and metadata live outside the project’s database, solist()stays a complete ledger after a restore.restorealways returns the202handle (restore_id,status: "running") instead of waiting, because a restore can outlast a function’s timeout; pollgetRestore. It restores app data only: restoring auth identities (include:["auth"]) and deleting platform snapshots stay with org admins (403 SNAPSHOT_AUTH_RESTORE_REQUIRES_PRINCIPAL,403 SNAPSHOT_KIND_NOT_DELETABLE_BY_SERVICE_KEY).release: "snapshot"re-activates the capture-time release with the data and re-deploys differing functions to their capture-time code (the plan lists them inrelease.functions_to_redeploy). Errors areR402SnapshotsErrorwith the gatewaycodeandnext_actions; a host without snapshots (Run402 Core) throwsSNAPSHOTS_UNSUPPORTED. A restore is destructive: decide in your app who may call it, and never put secrets in metadata.getRun402Context(req)— readsx-run402-*context headers acrossRequest/Headers/plain objects. Returns{ requestId, projectId, releaseId, host, locale, defaultLocale }(string|null), same asAstro.locals.run402; never throws.
TypeScript types: npm install @run402/functions@^4 to get full autocomplete for the auth.* namespace, db(req?), adminDb(), getRun402Context(), getRoutedPaymentContext(), email.send(), ai.translate(), ai.generateImage(), assets.put(), assets.list(), assets.delete(), assets.sign(), assets.signMany(), assets.fromRef(), push.send(), and repo.* / repos.*. Works in any Node.js/TypeScript project (Astro, Next.js, plain TS). For static site generation, use adminDb().from() at build time with RUN402_SERVICE_KEY + RUN402_PROJECT_ID in your .env.
db(req).from(table) — caller-context, RLS applies
Section titled “db(req).from(table) — caller-context, RLS applies”PostgREST-style queries scoped to the caller’s JWT role. Returns a plain array of row objects. Unauthenticated callers resolve to role=anon and see only what anon policies allow.
Reads:
.select(cols?)— columns to return (default"*").eq(col, val),.neq(),.gt(),.lt(),.gte(),.lte()— filters.like(col, pattern),.ilike(col, pattern)— pattern match.in(col, [values])— IN clause.order(col, { ascending? })— sort (default ascending).limit(n),.offset(n)— pagination
export default async (req: Request) => { // Runs with the caller's JWT — RLS decides what they see. const myItems = await db(req).from('items').select('title, done').limit(10); return new Response(JSON.stringify(myItems), { headers: { 'content-type': 'application/json' } });};Writes (also return an array of affected rows):
.insert(obj | obj[])— insert one or many rows.update(obj)— update matched rows (combine with.eq()).delete()— delete matched rows (combine with.eq())
// All three run as the caller — RLS policies decide if the write is allowed.const created = await db(req).from('items').insert({ title: 'New', done: false });await db(req).from('items').update({ done: true }).eq('id', 1);await db(req).from('items').delete().eq('id', 1);Column narrowing works with writes: .insert({...}).select('id, title') returns only those columns.
adminDb().from(table) — BYPASSRLS, opt-in
Section titled “adminDb().from(table) — BYPASSRLS, opt-in”Identical fluent surface to db(req).from(...) but uses the service_key. Returns all rows regardless of RLS. Use for server-side work where the function itself is the principal.
// Audit log — must capture every event regardless of who called the function.await adminDb().from('audit_log').insert({ event: 'payment_succeeded', user_id: userId });
// Cron cleanup — no caller context.await adminDb().from('sessions').delete().lt('expires_at', new Date().toISOString());adminDb().sql(query, params?) — raw SQL, always BYPASSRLS
Section titled “adminDb().sql(query, params?) — raw SQL, always BYPASSRLS”Returns { status, schema, rows, row_count, fields } (typed as AdminSqlResult in @run402/functions 3.10+). Field names are snake_case — the wire contract, NOT camelCase (rowCount was a docs error; it never existed at runtime).
- SELECT:
rows= matching rows,row_count= row count - INSERT/UPDATE/DELETE:
rows=[],row_count= affected rows - INSERT/UPDATE/DELETE with
RETURNING:rows= the returned rows fields= column metadata:[{ name, type }](Postgres type names, e.g.int4,timestamptz)- Parameterized:
adminDb().sql('SELECT * FROM t WHERE id = $1', [42])
const result = await adminDb().sql('SELECT * FROM users WHERE active = true');// { status: "ok", schema: "p0001", rows: [{ id: 1, name: "Alice" }], row_count: 1,// fields: [{ name: "id", type: "int4" }, { name: "name", type: "text" }] }const rows = result.rows;db is a request-scoped function. Use db(req).from(...) for caller-context RLS or adminDb().from(...) / adminDb().sql(...) for service-key work.
Calling functions from the browser
Section titled “Calling functions from the browser”Functions are accessible via HTTP at https://api.run402.com/functions/v1/<name>. This direct invoke path remains API-key protected even when apply-v1 web routes expose browser paths to the same function. Use the project’s anon_key as the apikey header. For authenticated calls, also pass the user’s access_token:
const res = await fetch('https://api.run402.com/functions/v1/my-function', { method: 'POST', headers: { apikey: ANON_KEY, Authorization: 'Bearer ' + session.access_token, // optional, for authenticated calls 'Content-Type': 'application/json' }, body: JSON.stringify({ key: 'value' })});run402 functions deploy <project_id> <name> --file <file> [--deps "<spec,...>"] [--timeout <s>] [--memory <mb>]— for triggered background work, preferrun402 deploy --manifestwithfunctions.replace.<name>.triggers[]so schedule ticks and email events create durable function runs.run402 functions invoke <name> --project <project_id> [--body '<json>' | --body-file <path>] [--method <GET|POST|...>] [--idempotency-key <key>] [--wait] [--timeout-ms <ms>] [--poll-interval-ms <ms>] [--raw]— inline and file bodies must contain valid JSON; invalid, empty, or shell-corrupted input fails locally before the function runs. Prefer--body-filefor agents and Windowscmd.exeso the shell cannot rewrite JSON quotes. The legacy positional forminvoke <project_id> <name>remains supported. Default output is{ http_status, body, duration_ms }(HTTP status surfaced ashttp_statusto avoid colliding with the reserved top-levelstatussentinel on stderr). Paid functions require a stable--idempotency-key; reuse it for the same paid intent. Without--wait, an in-progress paid call returnshttp_status: 202withrun_id/operation_idandnext_actions[]. With--wait, the CLI polls that run and replays the same key for the retained result.--rawskips the envelope: string body → text + newline, JSON body → pretty-printed JSON.run402 functions logs [<name>] [--project <project_id>] [--tail <n>] [--since <iso-timestamp>] [--request-id <req_...|fnrun_...|fnatt_...>] [--app|--platform|--all] [--follow]—<name>is optional when--request-idis given: the search then covers every function in the project (the output gainsrequest_id,scanned,errors?, and each entry carries itsfunction). App output only by default (--app);--platformshows only the Lambda runtime lines (INIT_START,START/END/REPORT RequestId),--allboth. Every entry carriesorigin: "app" | "platform";hiddencounts what the filter dropped andhintexplains an empty result that hid platform lines.--taildefaults to 50, is capped at 1000, and bounds the read before the filter.--followstill needs<name>. Non-follow mode emits a single{ logs: [...] }JSON object;--followmode emits NDJSON (one log entry per line) so streaming consumers can parse incrementally without a wrapping envelope.run402 functions runs create <project_id> <name> --event-type <type> --idempotency-key <key> [--payload-json <json-object>] [--delay <10m|1h|3d> | --run-at <iso>] [--expires-at <iso> | --expires-after <duration>] [--retry-preset standard] [--max-attempts <n>] [--wait]— creates a durable function request. The idempotency key is required; reuse it when retrying the same logical work item.run402 functions runs <list|get|logs|cancel|redrive> ...— list runs by function, fetch onefnrun_..., fetch correlated logs, cancel queued/scheduled work, or redrive a terminal run.redriveaccepts retry options and--wait. All outputs are JSON by default.run402 functions update <project_id> <name> [--timeout <s>] [--memory <mb>]— background triggers are declarative through ReleaseSpectriggers[]; the legacy schedule flags remain only for old simple-function surfaces.run402 functions rebuild <project_id> <name>/run402 functions rebuild <project_id> --all— opt-in refresh of a deployed function onto the platform’s current runtime/entry-wrapper. Re-bundles from the stored source with deps pinned to the recorded exact versions, so the sourcecode_hashis unchanged and no new release is created — only the platform wrapper/runtime changes. This is how a gateway-side wrapper fix (e.g. an SSRauth.*fix) reaches an already-deployed function: a plain redeploy with unchanged source does not pick it up. Single returns{ name, rebuilt, old_fingerprint, new_fingerprint, runtime_version_before, runtime_version_after, code_hash };--allreturns{ rebuilt_count, total, results: [...] }where each result is a rebuild record or{ name, rebuilt: false, code?, error }. Functions deployed before dependency locking returnCANNOT_REBUILD_UNLOCKED_DEPS(HTTP 409 for single, a per-function entry for--all) — redeploy them from source instead. Wallet-authed; allowed during billing grace (past_due/frozen/dormant).run402 functions <list|delete> <project_id> [<name>]
run402 doctor surfaces a runtime_staleness check (warning) listing any deployed functions on an older platform runtime, with the run402 functions rebuild --all remediation. Staleness is read-only — observing it never mutates a function.
For routed browser 500s, copy X-Run402-Request-Id or the JSON request_id from the response and run run402 functions logs <project> <function> --request-id req_.... --since is validated locally and should be supplied for incidents older than the default recent lookup window.
For durable runs, pass the run id or attempt id as the same filter: --request-id fnrun_... or --request-id fnatt_....
--tail must be a positive safe integer no larger than 1000, and --request-id must match req_..., fnrun_..., or fnatt_....
–deps semantics, runtime_version, deps_resolved
Section titled “–deps semantics, runtime_version, deps_resolved”--deps is a comma-separated list of npm specs that the gateway installs and bundles into the function zip alongside the user code:
- Bare names (
lodash) resolve to the latest published version at deploy time. - Pinned (
lodash@4.17.21) and range (date-fns@^3.6.0) specs are honored verbatim. - Entries are trimmed; empty entries e.g.
--deps "lodash,,date-fns"are rejected locally. @run402/functions(auto-bundled) and the legacyrun402-functionsname are rejected.- Limits: max 30 entries, max 200 chars per spec.
- Native binary modules (sharp, canvas, native bcrypt, etc.) are rejected.
run402 functions deploy routes through the unified apply engine, so its result sets runtime_version and deps_resolved to null (apply returns release-level data, not per-function build metadata). Read the resolved values from run402 functions list (and update). Function-list JSON carries the recorded, current, and guaranteed-minimum injected-runtime versions so agents can decide whether a deployed function has the helper surface they need:
runtime_version— the bundled@run402/functionsversion (e.g."1.48.0"). Surface this as “Functions runtime version” — never bare “runtime”, which already names the Node runtime (e.g.node22).nullfor legacy functions.runtime_current_version— the version the gateway injects into new releases; nullable only when the gateway cannot resolve its installed package version.runtime_minimum_version— the minimum injected helper version guaranteed by the platform. The current3.7.0floor includesgetRoutedPaymentContext()for priced routes.runtime_stale— whether the deployed bundle predates the current gateway wrapper/runtime. Userun402 functions rebuild <project_id> <name>(or--all) to refresh it; a plain unchanged-source redeploy does not.deps_resolved— map of each--depsname to the -installed concrete version (e.g.{"date-fns": "3.7.0"}for a^3.6.0spec). Direct deps only; this is not a lockfile.{}for an empty--deps;nullfor legacy functions.
The deploy result still includes an optional top-level warnings: string[] (sibling to the function record, not inside it) for non-fatal deploy notes e.g. bundle-size advisories. Omitted or [] when there are no warnings.
Function authoring limits by tier
Section titled “Function authoring limits by tier”| Prototype | Hobby | Team | |
|---|---|---|---|
| Max timeout | 10s | 30s | 60s |
| Max memory | 128 MB | 256 MB | 512 MB |
Max memory, SSR function (class: "ssr") |
128 MB | 256 MB | 1,769 MB |
| Max scheduled triggers | 1 | 3 | 10 |
| Min interval | 15 min | 5 min | 1 min |
run402 deploy preflights literal unified-deploy function specs against these caps before plan/upload when the values are known. Gateway validation remains authoritative; run402 tier status includes live function caps and current scheduled usage when returned.
Secrets available as process.env (see secrets below).
Make a function an MCP tool
Section titled “Make a function an MCP tool”Every app is an MCP server at https://<host>/_run402/mcp. Export a literal tool next to the handler and give the function exactly one exact route that accepts POST:
import type { ToolDeclaration } from "@run402/functions";export const tool = { description: "Cancel one of the caller's bookings.", input: { type: "object", properties: { booking_id: { type: "string" } }, required: ["booking_id"] },} satisfies ToolDeclaration;export default async (req: Request) => { /* req.json() is the tool arguments */ };A tool call is a POST to that route (header x-run402-trigger: mcp_tool), so requireAuth / requireRole, route prices, and logs apply as for a browser. Tools behind requireAuth sign the person in through the app’s own hosted sign-in and a consent page; the call then runs as them (auth.user(), db(req)). The export must be a literal (no variables or calls): the gateway parses it at commit and fails MCP_TOOL_NOT_STATIC, MCP_TOOL_SCHEMA_INVALID, MCP_TOOL_ROUTE_MISSING, MCP_TOOL_ROUTE_AMBIGUOUS, or MCP_TOOL_NAME_INVALID before any migration runs. After deploy, urls.mcp is the connector URL to hand the person. People see and disconnect their connected assistants at https://<host>/_run402/account/connections (signed-out visitors go through site.sign_in_path), or in your own page with auth.grants.list() / auth.grants.revoke(id).
secrets
Section titled “secrets”Injected as process.env in functions. Values are write-only — list returns keys and timestamps only, never values or value-derived hashes. Prefer --file or --stdin for real values so they do not land in shell history. --file - and --file /dev/stdin read stdin too.
run402 secrets set <project_id> <KEY> [<VALUE>] [--file <path>|--stdin]run402 secrets <list|delete> <project_id> [<KEY>]
Platform-managed jobs. This is not arbitrary Docker execution: submit a gateway-shaped request for a run402-configured job_type, then inspect status/logs, cancel one run, or purge all project runs. The SDK supplies the required idempotency header; the CLI does not expose it.
run402 jobs submit --file job.json [--project <project_id>]run402 jobs submit --stdin [--project <project_id>]run402 jobs get <job_id> [--project <project_id>]run402 jobs logs <job_id> [--project <project_id>] [--tail <n>] [--since <iso-timestamp>]run402 jobs cancel <job_id> [--project <project_id>]run402 jobs purge [--project <project_id>]run402 jobs artifacts get <job_id> <file> --output <path> [--project <project_id>]
Submit request shape:
{ "job_type": "example.managed_job.v1", "input": { "input_json": {} }, "max_cost_usd_micros": 50000}Artifacts: when a job completes, jobs get returns an artifacts map keyed by
filename. Each value is an object — { "url", "content_type", "sha256", "size_bytes" } — not a bare ref string. sha256/size_bytes may be absent on older
jobs; the url still serves. Download the bytes
with run402 jobs artifacts get <job_id> <file> --output <path> (auth is the
project service key, same as the rest of the jobs API). Discover recorded
filenames from the artifacts map; a 404 means the job has not completed or
the filename was not recorded for that run.
Due durable runs wait automatically while project concurrency slots are occupied. Waiting does not consume an execution attempt or retry budget. Keep the original run ID and inspect its status; do not cancel and recreate work merely because capacity is busy. Lifecycle and exhausted-quota blocks still require recovery.
Web push notifications: a deployed function sends with push.send(...) (above); a signed-in user signs a phone up from the app’s page (<script src="/_run402/push.js">, then Run402Push.subscribe({ accessToken })). These reads are the agent-side check. Auth: the project’s service_key, your wallet, a sign-in session, or a grant key with project.read. Guide: Push notifications.
run402 push status [--project <project_id>]—{ project_id, subscriptions: { total, users: [{ user_id, phones: [{ subscription_id, push_service, created_at, last_confirmed_at, last_accepted_at }] }], users_shown, users_total, has_more }, usage: { deliveries_today, limit, resets_at, scope: "organization", org_id }, messages }.messagesis the latest 10, each{ message_id, created_at, status, devices, outcomes: { queued, retrying, accepted, removed, failed } }; a message’sstatusisno_subscribers | queued | sending | completed.--projectdefaults to the active project.run402 push status --message <message_id> [--project <project_id>]— one message:title,body,url,tag,to({ user_id }ornullfor everyone),status,devices,outcomes, anddeliveries[]({ subscription_id, user_id, push_service, status, attempts, last_status, last_error, updated_at },deliveries_shown,has_more). A delivery’sstatusisqueued | retrying | accepted | removed | failed;removedmeans the push service said the phone is gone and the platform deleted its subscription. An unknown message id, or another project’s, is a 404.
The daily cap counts deliveries per organization per UTC day: 100 on prototype, 1,000 on hobby, 10,000 on team. A message that does not fit is refused whole: 403 QUOTA_EXCEEDED with an upgrade next action, or on team, which has no higher tier, 429 RATE_LIMITED with Retry-After until the UTC day resets. Push needs Run402 Cloud (PUSH_UNSUPPORTED on Run402 Core).
app-repos
Section titled “app-repos”Plain git repositories a project owns and its app writes: a function commits through repo / repos (above), people and agents clone and push with git, and a { type: "repo", repo, path?, requireAuth?, requireRole? } route target serves the latest commit with no deploy. Not encrypted and not a KyGit vault (that is run402 repos). <name> defaults to default, the repo every project has without creating it; --repo <name> may replace an optional leading <name>. Auth: the project’s service_key, your wallet, a sign-in session, or a grant key; reads need project.app_repos.read (viewer), create/delete/push need project.app_repos.write (developer, or a grant with deploy). Guide: App repos.
run402 app-repos list [--project <project_id>]—{ app_repos: [{ repo_name, head, seq, commit_count, size_bytes, size, clone_url, created_at, updated_at }] };defaultis always listed (head: nullbefore its first commit).run402 app-repos create <name> [--from <name>] [--project <project_id>]— the new repo.--fromstarts it with another repo’s files and history, sharing its objects. A taken name is409 APP_REPO_EXISTS.run402 app-repos delete <name> --confirm <name> [--project <project_id>]—{ deleted: true, repo_name }.defaultcannot be deleted (403 APP_REPO_DEFAULT_UNDELETABLE).run402 app-repos log [<name>] [--path <path>] [--cursor <cursor>] [--limit <n>] [--project <project_id>]—{ commits: [{ commit, parent, seq, author, committer, message, committed_at, paths, paths_total, actor_user_id }], head, has_more, next_cursor }, newest first;--pathkeeps the commits that changed that path;--limit1–100 (default 50).actor_user_idis the end user the gateway verified for an app commit.run402 app-repos cat [<name>] <path> [--at <commit>] [--project <project_id>]—{ repo_name, path, commit, oid, content_type, size_bytes, text }, orencoding: "base64"andbase64in place oftextfor bytes that are not UTF-8. A missing file (or any path of an empty repo) isAPP_REPO_PATH_NOT_FOUND, exit 1.run402 app-repos ls [<name>] [<dir>] [--path <dir>] [--at <commit>] [--project <project_id>]—{ commit, path, entries: [{ name, path, type: "file" | "dir", size, oid }] };commit: nulland no entries for an empty repo.run402 app-repos clone [<name>] [<dir>] [--dir <dir>] [--project <project_id>]— runsgit cloneofhttps://api.run402.com/projects/v1/<project_id>/app-repos/<name>.git(on the configured API base) into<dir>(default./<name>) and prints{ project_id, repo_name, dir, clone_url, credential_helper }. Git gets the credential fromrun402 app-repos credential, a git credential helper scoped to that URL in the clone’s config, which answers withRUN402_GRANT_KEYwhen set or the project’s service key from the local keystore; the key never lands in the remote URL or.git/config. Latergit pull/git pushreuse it.mainis the only branch, pushes must fast-forward (undo is a revert), each file is at most 4 MiB, and Git LFS is refused (APP_REPO_LFS_UNSUPPORTED). A push is live on the next read, with no deploy.
The SDK has the same reads plus r.appRepos.get, diff, commit and revert (MCP hosts reach them through run); clone is CLI-only.