Server actions are app-defined functions called from the client with the user’s JWT. They run as the app - RBAC checks are bypassed, so they can do things the user themselves can’t, like updating two collections atomically or running owner-only operations. Reach for server actions when you need to:Documentation Index
Fetch the complete documentation index at: https://docs.deep.space/llms.txt
Use this file to discover all available pages before exploring further.
- Orchestrate writes across multiple collections in one round-trip
- Run admin operations (recompute analytics, send notifications, mass-update records)
- Spend owner credits via an integration on behalf of the user
- Wrap business logic that needs server-side validation
useMutations on the client - keep server actions for cases that genuinely need escalation.
Define an action
POST /api/actions/inviteAttendee. The caller’s JWT is verified before the action runs.
Call from the client
The action context
Each action receives a context with the verified caller and a tools API:callerJwt is the verified Bearer token the action was invoked with. Forward it on outbound requests that must run as the caller (not the app owner) - see Forwarding caller identity.
tools - RBAC-bypassing operations
Every method returns ActionResult<T> - narrow with if (result.success) before reading result.data.
| Method | data shape on success | Notes |
|---|---|---|
tools.create(coll, data, recordId?) | { recordId } | Create a record. Pass recordId to upsert against a known key. |
tools.update(coll, id, patch) | { recordId } | Patch an existing record. |
tools.remove(coll, id) | { recordId } | Delete a record. |
tools.get(coll, id) | { record } | Fetch one record (envelope: { recordId, data, createdAt, updatedAt, ... }). |
tools.query(coll, opts?) | { records, count } | List records. opts accepts where, orderBy, orderDir, limit. |
tools.integration(endpoint, data?) | the integration’s response body directly | Call a third-party integration. Billing follows src/integrations.ts. |
Type tip.
tools.create/update/remove all resolve to ActionResult<MutateActionData> where MutateActionData is just { recordId: string } - there is no record field on the result. To read the resulting row after a mutation, follow up with tools.get(coll, recordId).tools.query bypasses caller RBAC - your action sees every record in the collection, not just records the caller could read. If you want caller-scoped reads, do them client-side with useQuery, or pass a where clause that scopes by caller.Upsert by known id
By defaulttools.create lets the DO mint the recordId. Pass an explicit id as the third argument to upsert against a known key - the canonical case is seeding the users row so its id matches the caller’s auth user id, which is what makes tools.get('users', userId) resolve later.
data is merged on top of it (existing fields you don’t pass are preserved), so the same call works for both first-time seed and subsequent refreshes.
Action return shape
Actions must returnActionResult<T>:
{ success, data, error } matching this shape.
Owner-only actions
When an action burns owner resources (credits, owner-billed integrations, sensitive owner-state mutations), gate it explicitly usingOWNER_USER_ID:
OWNER_USER_ID is set on every deployed app to the user who owns it. Use it as the trust anchor for owner-only operations.
Forwarding caller identity
tools.integration already routes the right JWT for you (owner or caller, depending on src/integrations.ts). If you need to call a platform endpoint directly - for example a platformWorkerFetch or apiWorkerFetch where the upstream authorizes the JWT subject as the user, not the app - use ctx.callerJwt to forward the same Bearer token the action was invoked with.
Integration calls - billing routing
tools.integration(endpoint, body) proxies through the api-worker. Billing depends on src/integrations.ts:
billing setting | Who pays |
|---|---|
'developer' | The app owner. Anonymous callers allowed. |
'user' | The signed-in caller. Anonymous callers get 401. |
When to use actions vs other patterns
| Need | Use |
|---|---|
| Single-collection mutation the user can do | useMutations |
| Multi-collection orchestration | Server action |
| Owner-billed integration call | Server action with owner gate, or cron |
| Admin operation (mass update, recompute) | Server action |
| Streaming response | Custom Hono route (actions don’t stream) |
| Scheduled work | Cron (see Scheduled jobs) |
Testing server actions
A server action is one POST endpoint; cover it inapi.spec.ts:
Tips
- Keep actions focused. One verb per action (
inviteAttendee, notmanageEvent). Easier to test, easier to reason about. - Don’t put RBAC logic inside actions. That’s what the DO’s collection permissions are for. Actions should be for orchestration and owner-gating.
- Prefer actions over ad-hoc
fetchendpoints. ThetoolsAPI gives you type-safe RBAC bypass; rolling your own endpoint loses that. - Use the caller’s userId for audit logs.
ctx.userIdis the verified caller; record it alongside any privileged write so you can trace who initiated it.
Next steps
- Server actions reference -
ActionHandler,ActionContext,ActionResulttypes. - Permissions - collection-level RBAC.
- External APIs - call third-party services from actions.