Agent index (llms.txt) — fetch this first, then the page it names.

Functions

Orama Network runs serverless functions as sandboxed WebAssembly (WASM) modules. Functions are written in Go, compiled to WASM with TinyGo, and executed in an isolated wazero runtime with configurable memory limits and timeouts.

Functions receive input via stdin (JSON) and return output via stdout (JSON). They can also access Orama services — database, cache, storage, secrets, PubSub, and HTTP — through host functions injected by the runtime.

Quick Start

bash
# 1. Scaffold a new function
orama function init my-function

# 2. Edit your handler
cd my-function
# edit function.go

# 3. Build to WASM
orama function build

# 4. Deploy
orama function deploy

# 5. Invoke
orama function invoke my-function --data '{"name": "World"}'

# 6. View logs
orama function logs my-function

Project Structure

my-function/
├── function.go      # Handler code
├── function.yaml    # Configuration
├── go.mod           # Go module, named after the function
└── fn/fn.go         # The function SDK (`sdk/fn`), copied in

orama function init writes all four. The SDK is copied into the project because its import path in this repository is not one a function's module can fetch, and so orama function build fetches nothing. TinyGo builds a module, so a function written by hand needs a go.mod too; a handler that uses only the standard library needs nothing else.

function.yaml

yaml
name: my-function       # Required. Letters, digits, hyphens, underscores.
public: false           # Allow unauthenticated invocation (default: false).
                        # Private functions require a SIWE wallet JWT or an
                        # API key that holds the `invoke` grant. A storage-only
                        # key is rejected.
memory: 64              # Memory limit in MB (1-256, default: 64). Enforced per
                        # invocation: growing past it fails the call, and a
                        # module whose minimum memory is already above it is
                        # refused (command-mode functions; reactor/persistent
                        # instances only get the runtime-wide 256 MB cap).
                        # A deploy whose memory or timeout is not a whole number,
                        # is below 1, or is above the gateway's maximum (256 MB,
                        # 60 s) is refused 400 VALIDATION_FAILED naming the field.
timeout: 30             # Execution timeout in seconds (1-60, default: 30)
                        # Raise it toward the 60 s maximum for batch DB ops, schema migrations,
                        # or anything that does many sequential host calls.
                        # Exceeding this timeout on a direct invoke returns
                        # HTTP 429 {ok:false, error:{code:"RATE_LIMITED",...}}
                        # (retryable). The code "TIMEOUT" (HTTP 504) appears
                        # only when the namespace-proxy budget is exceeded.
retry:
  count: 0              # Retry attempts on failure (0-5, default: 0 = never retry;
                        # a count above the gateway's max_retry_count or a negative
                        # count or delay is refused 400 VALIDATION_FAILED)
  delay: 5              # Seconds between retries (default: 5)
env:                    # Environment variables (accessible via get_env)
  MY_VAR: "value"
ws_auth: capability     # Optional. Also let the function's WebSocket be opened
                        # with a capability it minted, instead of a credential
                        # (see "Capabilities" below). Empty (default): a
                        # credential only. Refused on an internal function.

function.go

The SDK helper package fn handles the stdin/stdout boilerplate. This is the template orama function init generates:

go
package main

import "github.com/DeBrosOfficial/network/sdk/fn"

func main() {
	fn.Run(func(input []byte) ([]byte, error) {
		var req struct {
			Name string `json:"name"`
		}
		fn.ParseJSON(input, &req)
		if req.Name == "" {
			req.Name = "World"
		}
		return fn.JSON(map[string]string{
			"greeting": "Hello, " + req.Name + "!",
		})
	})
}
FunctionDescription
fn.Run(handler)Reads input from stdin, calls the handler, writes output to stdout
fn.JSON(v)Marshals a value to JSON bytes
fn.ParseJSON(data, v)Unmarshals JSON bytes into a value

function.go without the SDK (minimal)

go
package main

import (
    "encoding/json"
    "os"
)

func main() {
    // Read JSON input from stdin
    var input []byte
    buf := make([]byte, 4096)
    for {
        n, err := os.Stdin.Read(buf)
        if n > 0 {
            input = append(input, buf[:n]...)
        }
        if err != nil {
            break
        }
    }

    var payload map[string]interface{}
    json.Unmarshal(input, &payload)

    // Process and return JSON output via stdout
    response := map[string]interface{}{
        "result": "Hello!",
    }
    output, _ := json.Marshal(response)
    os.Stdout.Write(output)
}

Building

Functions are compiled to WASM using TinyGo:

bash
# Using the CLI (recommended)
orama function build

# Or manually
tinygo build -o function.wasm -target wasi function.go

⚠️ orama function deploy reuses an existing function.wasm. It only auto-builds when function.wasm is absent. If you edit function.go but a stale function.wasm from a previous build is still on disk, deploy uploads the old binary and your changes silently never ship — which looks exactly like "the gateway isn't picking up my update". After editing source, run orama function build (which overwrites the wasm) or rm function.wasm before orama function deploy. Redeploys otherwise take effect immediately (new version → new WASM CID → the runtime loads it on the next invoke).

ℹ️ A function lives on its namespace's gateway. A function is deployed into the namespace of the credential that deploys it, and stored in and run by that namespace's own gateway, https://ns-<ns>.<domain>, against the namespace's own RQLite. orama function deploy targets the gateway of your active CLI environment (orama network); point it at the namespace gateway (orama network add <name> https://ns-<ns>.<domain> then orama network use <name>), or set ORAMA_API_URL.

A gateway runs only its own namespace's functions, whichever path asks — an HTTP or WebSocket invoke, a cron or pubsub trigger, the JWT claims provider, a nested function_invoke. The cluster's main gateway is the default namespace's. Any other function request that reaches it — deploy, list, logs, secrets, triggers, invoke, the function WebSocket — is proxied to the namespace's gateway: the namespace named by /v1/invoke/<ns>/<name> or ?namespace=, otherwise the credential's. A credential of another namespace is refused (NAMESPACE_MISMATCH), and an anonymous request that names no namespace is refused with a message pointing at https://ns-<namespace>.<domain>. A signed-in wallet or an exchanged-key token managing functions through that proxy has its grant read from the cluster registry by the namespace gateway.

Tenant functions deployed into the main gateway's registry before 0.200.0 are no longer run there — not by requests, which are proxied to the namespace gateway, and not by the triggers that pointed at them, which the main gateway now refuses to fire. Redeploy them to the namespace gateway. A token minted by the main gateway runs no tenant's auth-claims-provider; sign in through the namespace gateway for its custom claims.

ORAMA_API_URL, ORAMA_GATEWAY_URL and ORAMA_GATEWAY all name the gateway and are read in that order; the credential is always the one stored for whichever gateway wins, so pointing the CLI at a namespace gateway you have not authenticated against fails instead of sending another gateway's key. With none of them set and no active environment, commands stop with an error rather than defaulting to a network.

A complete example

core/examples/demo/ in the repository is a small app you can put on a network in a few minutes: three public functions (hello, which reads the caller; visits, an atomic counter in the namespace's cache; guestbook, rows in the namespace's database), a static page that calls them through /v1/invoke/<namespace>/<name>, and a README with the exact orama function build, orama function deploy and orama deploy static commands. Each function keeps its logic apart from the host functions, so it is unit-tested against an in-memory host and built to WASM with TinyGo from the same code.

Deploying over the REST API

Upload a multipart form with the compiled WASM binary and its configuration:

bash
curl -X POST https://ns-myapp.example.com/v1/functions \
  -H "Authorization: Bearer <token>" \
  -F "name=hello-world" \
  -F "is_public=false" \
  -F "memory_limit_mb=64" \
  -F "timeout_seconds=30" \
  -F "retry_count=0" \
  -F "retry_delay_seconds=5" \
  -F "wasm=@./function.wasm"

Managing functions

bash
orama function list
orama function get my-function
orama function versions my-function
orama function logs my-function --limit 100
orama function disable my-function
orama function enable my-function
orama function delete my-function --force

Host Functions API

Host functions let your WASM code interact with Orama services. They use a pointer/length ABI for string parameters and are registered at runtime under three module-name aliases — all three resolve to the SAME function table:

Module nameStatusUse
envcanonicalRecommended for new code. Matches the WASI / TinyGo convention used by every example in this doc and the sdk/fn package.
hostalias (kept)Long-standing alternative; supported indefinitely.
oramaalias (kept)Brand-name alias; supported indefinitely so existing code that intuited this name keeps working.

A function may import any host call from any of the three names interchangeably:

go
//go:wasmimport env   db_query     // canonical (preferred)
//go:wasmimport host  db_query     // identical
//go:wasmimport orama db_query     // identical

If you see the runtime error failed to instantiate module: module[X] not instantiated, your function imported from a name other than the three above — fix the directive. Most functions written using the sdk/fn package don't need any //go:wasmimport directives at all (the SDK uses stdin/stdout for I/O).

Context

FunctionDescription
get_caller_wallet() → stringResolved caller wallet (JWT subject if Bearer auth, else namespace pseudo-id when API-key auth).
get_caller_jwt_subject() → stringJWT sub claim explicitly. Empty when the request was not JWT-authenticated. Use this when binding on the JWT-signed identity matters (e.g. signup flows verifying the caller signed for the wallet they're registering).
get_caller_device_id() → stringThe device the caller's session is bound to: the RFC 7638 thumbprint of a key the device proved it holds when the session was issued (the token's did). Empty for a session bound to the account alone, an API key, or no credential. Set only by the gateway — a claims provider cannot, and a client cannot state it. Carried into nested function_invoke calls. Requires gateway 0.200.0 or later on every node of the namespace. See the technical reference's identity chapter (docs/whitepaper/technical-reference/vol1/13-identity.md).
get_caller_capability() → stringWhat the capability the caller's socket was opened with grants, as JSON {"cap_id","resource","issuer_device","expires_at"}. Empty when the caller came in on a credential. Not carried into nested function_invoke calls. See Capabilities.
get_caller_claim(name) → stringCustom JWT claim by name (tier, subscription, etc.). Empty if missing or non-JWT request.
get_request_id() → stringUnique invocation ID
get_env(key) → stringEnvironment variable from function.yaml
get_secret(name) → stringDecrypted secret value (see Managing Secrets)

Database (RQLite)

FunctionDescription
db_query_v2(sql, argsJSON) → JSONRecommended. Execute SELECT. Returns {"rows": [...], "error": "...", "code": "..."} — distinguishes empty result from query failure. code classifies the failure; see the table under Database Transactions.
db_execute_v2(sql, argsJSON) → JSONRecommended. Execute INSERT/UPDATE/DELETE. Returns {"rows_affected": N, "last_insert_id": M, "error": "...", "code": "..."} — distinguishes 0-rows-affected from a real failure. A duplicate on a unique key is code: "CONSTRAINT_VIOLATION" with error naming the constraint.
db_query(sql, argsJSON) → JSONLegacy. Execute SELECT, returns JSON array of rows. No way to surface query errors — prefer db_query_v2.
db_execute(sql, argsJSON) → intLegacy. Returns affected rows ONLY. Returns 0 for both "0 rows" and "SQL error" — caller can't distinguish. Prefer db_execute_v2.
db_transaction(opsJSON) → JSONAtomic batch — see "Database Transactions" below.

Example v2 usage from WASM:

go
//go:wasmimport env db_execute_v2
func dbExecuteV2(sqlPtr, sqlLen, argsPtr, argsLen uint32) uint64

resultBytes := callDBExecuteV2(`INSERT INTO event_seq (topic, next_seq) VALUES (?, 0)
                                ON CONFLICT(topic) DO NOTHING`,
                                []any{"user/abc/account"})

var res struct {
    RowsAffected int64  `json:"rows_affected"`
    Error        string `json:"error"`
}
json.Unmarshal(resultBytes, &res)
if res.Error != "" {
    // Real failure — bail out, don't mark migration applied.
    return fmt.Errorf("event_seq INSERT failed: %s", res.Error)
}
// res.RowsAffected may legitimately be 0 (ON CONFLICT DO NOTHING) — that's not an error.

The legacy db_execute is kept indefinitely so existing functions don't break. New code should use db_execute_v2 for any path where distinguishing "no rows" from "SQL error" matters — most paths.

What a function's SQL may not do. The same filter runs on the namespace gateway's raw-database routes (see the technical reference's database chapter (docs/whitepaper/technical-reference/vol1/17-database.md)). Every database host function refuses, before anything runs: a second statement in one call; ATTACH, DETACH, PRAGMA, VACUUM and CREATE TRIGGER; sqlite_dbpage and dbstat; any identifier that is a platform table's name, however it is quoted and in any role — table, column, alias or named parameter; and a string literal whose whole text is one, such as VALUES ('grants'), because SQLite reads a string literal as a table name in many positions. Pass such a value as a bound argument (?) instead. A refusal is a failed host call: db_query_v2 and db_execute_v2 return 0, which a function reads as no result at all (an accepted statement always returns a result object), and the reason goes to the gateway's log.

The reserved names are api_keys, wallet_api_keys, refresh_tokens, nonces, device_authorizations, session_devices, namespace_session_policy, invite_tokens, operators, cluster_settings, namespace_creators, release_installs, principals, signing_keys, node_credentials, encryption_roots, grants, namespace_ownership (0.122.x's ownership table, kept for the rolling upgrade), api_keys_expiry_cutoff, wireguard_peers, namespace_push_credentials, push_topics, function_secrets, function_env_vars, revoked_tokens, audit_events, status_uptime_hourly, namespace_quotas, namespace_rate_limit_config, namespace_clusters, namespace_cluster_nodes, namespace_port_allocations, global_deployment_subdomains, dns_records, dns_nodes, dns_nameservers, raft_evicted_nodes, cluster_locks, tls_store, tls_locks, orama_schema_migrations, namespaces, ipfs_content_ownership, ipfs_cid_refs, deployments, deployment_domains, deployment_replicas, home_node_assignments, port_allocations, functions, function_cron_triggers, function_pubsub_triggers, function_db_triggers, deployment_history, deployment_events, deployment_health_checks, namespace_push_config, namespace_webrtc_config, namespace_sqlite_databases, namespace_sqlite_backups, webrtc_rooms, webrtc_settings, webrtc_admissions, webrtc_port_allocations, namespace_cluster_events, namespace_pending_cleanup, node_health_events, rqlite_backups, _pubsub_mesh_peers and _namespace_libp2p_peers (the list in core/pkg/sqlguard/sqlguard.go, which is also what the namespace gateway's raw-database routes apply to a tenant's SQL: see the technical reference's database chapter (docs/whitepaper/technical-reference/vol1/17-database.md)). A deployment is created and changed through /v1/deployments, which validates its content, entry point and port; SQL against the deployment tables would skip all of that, so they are reserved. The tables that only record what happened (deployment_events, deployment_health_checks) are not. functions is a generic name: an application that owns a table of that name cannot use it from a function or over /v1/rqlite, and must rename it.

A function's database is its own namespace's. Every database host function also refuses a call from a function whose namespace is not the one the gateway's database belongs to — the gateway's own client_namespace — and a call made outside an invocation (a warm-pool module's _initialize), where there is no namespace to check. On a namespace gateway that is the tenant's own namespace. On the cluster gateway no function gets a database, the default namespace's included: its database is the cluster registry, which holds every tenant's deployments, deployment_domains, functions and trigger rows side by side, so a function reaching it could rewrite another tenant's routing. The refusal is a host call error naming the function's namespace gateway.

Database Transactions

db_transaction(opsJSON) runs a set of statements as one atomic batch.

Input — {"ops": [{"kind": "exec"|"query", "sql": "...", "args": [...]}, ...]}

Output — a BatchResult:

json
{
  "results": [{"kind": "exec", "rows_affected": 1, "last_insert_id": 42}],
  "committed": true,
  "failed_index": 0,
  "error": "",
  "code": ""
}

Always check committed. A non-empty return does not imply the writes landed.

  • +committed: true — every exec op is durable.
  • +committed: false with failed_index set — one statement failed and the whole batch rolled back; that op's results entry carries its SQL error.
  • +error non-empty — the batch failed as a whole, for a reason belonging to no single statement: a limit violation, an expired deadline, a lost leader, a transport fault. code classifies it.

Every failed op's results entry carries its own code as well, next to its error. For db_transaction a statement failure — a CONSTRAINT_VIOLATION included — is reported only there, in results[failed_index]; the top-level error/code stay empty. exec_and_publish additionally copies the failing op's reason to its top-level error/code. Branch on the code; the SQLite wording in error is for logs, not a contract.

Batch-level code is sent from 0.122.103; per-op code and the code on db_execute_v2 / db_query_v2 from 0.200.0. An older gateway sends error without code: treat a missing code as unclassified.

code values — always set together with error, never on success. The same codes appear on batch-level failures, on each failed op, and on db_execute_v2 / db_query_v2:

CodeMeaningRetry?
CONSTRAINT_VIOLATIONA statement violated a UNIQUE, PRIMARY KEY, NOT NULL, CHECK or FOREIGN KEY constraint. Which one is in error. A trigger's RAISE(ABORT, …) whose message contains one of those <KIND> constraint failed phrases produces it too, so check error before treating it as "already recorded"No — the same write fails the same way every time
TOO_MANY_STATEMENTSOver the per-batch statement capNo — split the batch
PAYLOAD_TOO_LARGEResult rows/bytes over the capsNo — paginate
DEADLINE_EXCEEDEDThe call ran past its deadlineYes
UNAVAILABLEDatabase unreachable, or leader lost mid-callYes
INVALID_ARGUMENTMalformed request (bad JSON, unknown op kind)No — fix the caller
INTERNALUnclassified — read error. Every other statement SQLite rejects (missing table or column, syntax error) is INTERNAL: a statement failure is never given a transport code, whatever identifiers its message namesDepends; a statement failure usually will not succeed on retry (a full disk or a locked database can)

The same error + code pair is returned by db_query_batch and exec_and_publish when they fail at the host level. None of the three ever returns an empty buffer: an empty result used to be indistinguishable from a deadline, an oversized payload or a node that died mid-commit, which made a real namespace outage diagnosable only by guessing.

Semantics

  • +All exec ops go to RQLite in one transactional request. Any failure rolls back the entire batch.
  • +query ops are sequenced after the exec batch commits, on the same node, and see the committed writes. A failing query does not roll back the writes — its own results entry gets error and committed stays true.
  • +Result order always matches input order.

Limits — exceeding the statement cap or the deadline fails the whole batch with error set. The row cap truncates only that op's rows; the byte cap is per batch, and every op after the budget runs out comes back with no rows. Both set that op's code: PAYLOAD_TOO_LARGE:

LimitValueApplies to
Statements per batch100db_transaction, db_query_batch, exec_and_publish
Rows returned per query op10,000db_query_batch
Total result bytes per batch32 MiBdb_query_batch
Batch round-trip deadline10sdb_query_batch

⚠️ The 100-statement cap is the one that bites in practice. A write whose size scales with fan-out — one row per group participant, per device — crosses it as the group grows, and it fails deterministically at 101, not under load. Split such work into chunks of ≤100 and drive them as separate transactions, or restructure so a single statement covers many rows.

go
resultBytes := callDBTransaction(ops)

var res struct {
    Results []struct {
        Error string `json:"error"`
        Code  string `json:"code"`
    } `json:"results"`
    Committed   bool   `json:"committed"`
    FailedIndex int    `json:"failed_index"`
    Error       string `json:"error"`
    Code        string `json:"code"`
}
json.Unmarshal(resultBytes, &res)

if res.Error != "" {
    switch res.Code {
    case "DEADLINE_EXCEEDED", "UNAVAILABLE":
        return retryLater(res.Error)                     // transient
    default:
        return fmt.Errorf("batch rejected [%s]: %s", res.Code, res.Error)
    }
}
if !res.Committed {
    failed := res.Results[res.FailedIndex]
    if failed.Code == "CONSTRAINT_VIOLATION" {
        return errAlreadyRecorded(failed.Error)          // do not retry
    }
    return fmt.Errorf("batch rolled back at op %d [%s]: %s", res.FailedIndex, failed.Code, failed.Error)
}

Read consistency — db_query_batch (not db_transaction) accepts an optional consistency field on the request body:

  • +omitted / "weak" (default) — leader-routed, always sees the latest committed write. Costs one round trip to the raft leader.
  • +"none" — served from the local node's SQLite. Much faster, but may be stale; only safe for reads that do not need read-your-own-writes.

db_query_v2 does not accept consistency; wrap a single read as a one-op db_query_batch if you need it.

Cache (Olric Distributed Cache)

FunctionDescription
cache_get(key) → bytesGet cached value by key. Returns empty on miss.
cache_set(key, value, ttl)Store value. ttl is in seconds: > 0 expires the entry after that long (at most olric.MaxEntryTTL, 10 years), 0 means no expiry (lives until cache_delete), negative or longer is refused. No return value; a failure is logged by the gateway.
cache_delete(key) → u32Remove a key. Returns 1 when the key is gone afterwards (including when it was never set), 0 on failure. The only way to clear a no-expiry entry. Requires gateway 0.200.0 or later on every node of the namespace: wazero resolves imports at instantiation, so a function importing it fails every invocation on an older gateway.
cache_incr(key) → int64Atomically increment by 1 (init to 0 if missing). Returns the new value, or 0 on failure.
cache_incr_by(key, delta) → int64Atomically increment by delta (may be negative). Same return.

Each namespace has its own cache map (:serverless_cache:<namespace>), so a function reaches only its own namespace's keys, whichever Olric the gateway running it talks to. The cache host functions need an invocation, so they are unavailable while a warm-pool (stateless) reactor module runs _initialize; call them from handle().

Counter guarantees. cache_incr and cache_incr_by are one call to Olric's atomic Incr on the key's partition owner, under a per-key lock; nothing reads the value and writes it back. So:

  • +Concurrent increments from any number of invocations, on any number of the namespace's gateways, are all counted, and each returns a distinct value: n increments of 1 on a new key return 1..n, each once.
  • +An increment never sets or changes an expiry. A counter an increment created has none (clear it with cache_delete); a counter cache_set stored with a ttl keeps that deadline through increments, and once it expires the next increment starts again from 0. To count in a window, cache_set the key with the window as its ttl first; do not expect cache_incr to start the clock.
  • +A value that is missing, expired or not a number counts as 0 and is replaced by the result. cache_get on a counter returns its decimal text.
  • +A failed call returns 0 to the function and is not retried by the platform. When the failure is a timeout the increment may or may not have been applied, so a function that must not double count should read the counter back (cache_incr_by(key, 0)) before it tries again.
  • +A request the gateway refuses before it reaches the function (for example a retryable 503 SERVICE_UNAVAILABLE from the proxy) never ran, so it changed nothing; a client that retries it counts once.

Upgrading to 0.200.0. Before 0.200.0 every function shared one serverless_cache map and every entry was written without an expiry. After the upgrade those entries are invisible to functions, which is also what clears values that were stuck forever. While a namespace's gateways are on mixed versions, old and new nodes read different maps, so a value or counter written through one is not seen through the other; do not rely on cache state across the rollout. The old map keeps its entries in memory until the Olric members that hold it restart: the namespace's Olric, or the core Olric for functions run by the cluster gateway.

HTTP

FunctionDescription
http_fetch(method, url, headersJSON, body) → JSONMake outbound HTTP request. Headers as JSON object. Returns {"status": 200, "headers": {...}, "body": "..."}. Timeout: 30s. The destination is checked on the socket, so a hostname that resolves to an internal address and a redirect to one are both refused, not just an internal address written literally in the URL.
anon_fetch(method, url, headersJSON, body) → JSONhttp_fetch through the node's Tor client: the destination sees a Tor exit address, not the gateway's. Same signature, same envelope and 30s timeout. Every connection goes through Tor — there is no fallback to a direct request. The host name is resolved by the exit, and Tor refuses private and local addresses, including on a redirect. When Tor is down the call returns {"status": 0, "error": "..."}.
anyone_fetch(method, url, headersJSON, body) → JSONDeprecated alias of anon_fetch: the same implementation, routed through Tor. It keeps the name it had while the anonymity backend was the Anyone network, which Orama no longer uses, because deployed functions import it and a module with a missing import fails to instantiate. New code imports anon_fetch.

Storage (IPFS)

There is no storage_put / storage_get host function exposed to WASM — a function cannot pin to or read IPFS directly. Storage is done over the gateway HTTP API (POST /v1/storage/upload, GET /v1/storage/get/:cid).

⚠️ Those storage endpoints require a logged-in user (JWT) — an API key alone is rejected (the 'storage' operation requires a logged-in user; an API key alone is not sufficient). So a function cannot upload via http_fetch with an API-key secret either. The pattern that works: the client uploads with its user JWT and passes the resulting CID to a function (which records/uses it). If your function truly needs to originate IPFS content, it needs a user-JWT credential, not a namespace API key. (A deployed app, as opposed to a function, holds its own workload token, which these endpoints accept within its grant: the technical reference's authorization chapter (docs/whitepaper/technical-reference/vol1/14-authorization.md).)

PubSub

FunctionDescription
pubsub_publish(topic, dataJSON) → boolPublish message to a PubSub topic. Returns true on success.

Push

FunctionDescription
push_send(userID, msgJSON) → u32Push to every device the user registered in this namespace. 1 = ok, 0 = failure. Silent no-op (1) when push is not configured.
push_send_v2(userID, msgJSON) → u64Same, returning a packed ptr<<32|len JSON envelope with a result per device (HTTP status, reason, unregistered). 0 on an invalid call.
push_send_topic(topicID, msgJSON) → u64Push to the device registered under a rotating push topic in this namespace. Same envelope as push_send_v2; an unknown or expired topic is ok:false with one result whose reason is TopicNotFound. 0 on an invalid call or when the gateway cannot look the topic up.

The namespace is always the invocation's own. msgJSON, the topic model and the envelope are described in Push Notifications.

go
//go:wasmimport env push_send_topic
func pushSendTopic(topicIDPtr *byte, topicIDLen uint32, msgPtr *byte, msgLen uint32) uint64 // ptr<<32|len of JSON

Ephemeral State (WS-subscribe-tracked)

Short-lived per-subscriber state (typing indicators, presence, call ringing, live cursors) that the gateway auto-clears the moment the owning WebSocket client disconnects — no heartbeats, no prune crons. State also expires on a TTL backstop (default 60 s, max 30 min). The owning client ID and namespace come from the server-trusted invocation context; functions cannot spoof them.

FunctionDescription
ephemeral_state_set(topic, key, payload, ttlMs) → u32Record state owned by the CURRENT invocation's WS client and publish an ephemeral.set event on the topic. 1 = ok, 0 = failure (no WS client, empty topic/key, payload > 16 KiB, > 256 keys/client).
ephemeral_state_clear(topic, key) → u32Clear state this client owns; publishes ephemeral.clear (reason explicit). Idempotent — clearing a missing/non-owned key returns 1.
ephemeral_state_list(topic) → u64Reconnect catch-up read: packed ptr<<32|len of a JSON envelope with the live entries on the topic. Works without a WS client (read-only). 0 on failure.

Raw import signatures (pointer/length ABI — note ttlMs is i64):

go
//go:wasmimport env ephemeral_state_set
func ephemeralStateSet(topicPtr *byte, topicLen uint32, keyPtr *byte, keyLen uint32,
	payloadPtr *byte, payloadLen uint32, ttlMs int64) uint32

//go:wasmimport env ephemeral_state_clear
func ephemeralStateClear(topicPtr *byte, topicLen uint32, keyPtr *byte, keyLen uint32) uint32

//go:wasmimport env ephemeral_state_list
func ephemeralStateList(topicPtr *byte, topicLen uint32) uint64 // ptr<<32|len of JSON

Synthetic events are published on the same topic the state lives on, with the _orama control-frame discriminator (same dispatch pattern as the auth.refresh frame). Subscribers update their local view from the stream:

jsonl
{"_orama":"ephemeral.set",  "topic":"typing:room1", "key":"user-7", "client_id":"ws-abc", "payload":"<base64>"}
{"_orama":"ephemeral.clear","topic":"typing:room1", "key":"user-7", "client_id":"ws-abc", "reason":"disconnect"}

reason is explicit (function called clear), disconnect (owning WS client went away — the zero-lag path), or expired (TTL backstop). payload is base64 (Go []byte JSON encoding) and present only on ephemeral.set.

ephemeral_state_list returns:

json
{"entries":[{"key":"user-7","client_id":"ws-abc","payload":"<base64>","expires_in_ms":48211}]}

Typing-indicator shape (called from a ws_persistent rpc-router function):

go
// Client sends {"op":"typing.start","room":"room1","user":"user-7"} → handler:
ephemeralStateSet(ptr("typing:"+room), len32("typing:"+room),
	ptr(userID), len32(userID), nil, 0, 30_000) // 30s TTL backstop

// Client sends typing.stop → handler:
ephemeralStateClear(ptr("typing:"+room), len32("typing:"+room), ptr(userID), len32(userID))

// No typing.stop needed on app kill / network drop: the WS disconnect publishes
// {"_orama":"ephemeral.clear",...,"reason":"disconnect"} to every subscriber
// immediately. On (re)connect, call ephemeral_state_list("typing:"+room) once
// to seed local state, then track the event stream.

Pub/sub delivery to WebSocket clients

ws_pubsub_bridge(client_id, topic) forwards every message on a topic to a function WebSocket client without invoking the function per message (and ws_pubsub_unbridge stops it). Two rules protect what the client reads:

  • +

    _orama is reserved. A payload that is a JSON object with a top-level _orama key (any letter case) is refused on POST /v1/pubsub/publish and /v1/pubsub/publish-batch for every caller, the namespace owner included: 400 PUBSUB_RESERVED_KEY, and a batch is refused whole, naming the item. The key marks events the platform publishes itself (ephemeral.set, ephemeral.clear above), which do not go through those routes. Frames sent on a subscribe socket are held to the same rule. Rename the key in your own events. The check fails closed: a payload that starts as a JSON object but is not strict JSON (a byte-order mark before invalid JSON, trailing commas, nesting deeper than 10,000 levels) could still be an object, with the key in it, to a lenient subscriber, so it is refused 400 PUBSUB_INVALID_OBJECT. A leading byte-order mark before valid JSON is read through.

  • +

    Platform topics are bridged like any other. A function runs as the namespace, so ws_pubsub_bridge may bridge a reserved _orama/ topic (the WebRTC membership topic _orama/webrtc/<room>) to a client. That is the application's decision and the platform does not narrow it: a function that passes user input as the topic exposes every room's membership to that user. Fix the topic in the function (_orama/webrtc/ plus a room the user was admitted to), never take it from the request (WebRTC).

  • +

    Stamped delivery (opt-in). By default the client receives the publisher's bytes unchanged, so anything in them, a topic field included, is what the publisher chose to claim. Open the function WebSocket with ?pubsub_delivery=stamped and every bridged message arrives instead as

    json
    {"_orama":"pubsub.message","topic":"chat.general","data_base64":"<publisher bytes, base64>"}

    topic is the topic the platform delivered the message on, and the payload is untouched inside data_base64, so nothing in it can be read as part of the envelope. A client that does not pass the parameter sees no change; a client that does must base64-decode data_base64 and dispatch on topic from the envelope, not from the payload. Platform events (ephemeral.*) arrive inside data_base64 like any other message. The option applies to both the stateless and the persistent function WebSocket.

Logging

FunctionDescription
log_info(message)Log info-level message (captured in invocation logs).
log_error(message)Log error-level message.

Other host functions

FunctionDescription
get_ws_client_id() → stringThe WebSocket client ID of the current invocation, or empty when the function was not invoked over a WebSocket.
ws_send(clientID, data)Send data to one WebSocket client.
ws_broadcast(topic, data)Send data to every WebSocket client subscribed to a topic.
function_invoke(name, payload) → bytesInvoke another function of the same namespace and wait for its output. The target inherits the caller's identity.
function_invoke_async(name, payload)Start another function and return at once. The call fails only when it cannot be accepted (no invoker, or the in-flight cap is reached); failures inside the target surface in the target's own logs or ws_send.
db_query_batch(opsJSON)Several reads in one round trip, with an optional consistency (see Database Transactions).
exec_and_publish(opsJSON)Run a transactional batch and publish to PubSub once it commits. committed: true with published: false and a publish_error means the writes are durable and only the publish can be retried.
pubsub_publish_batch(msgsJSON)Publish several messages, failing fast on the first error.
turn_credentials() → JSONShort-lived TURN credentials for the namespace. When TURN is not configured the answer is {"configured": false}, not an error.
set_http_response(status, headersJSON, body)For a function that opted in to raw HTTP responses: replay this response byte for byte to the HTTP caller.

Configuring Push Notifications (per-namespace)

Push providers (ntfy / Expo) are configured per namespace by the tenant — no operator involvement, no SSH access required. Set, read, or clear via:

bash
# Set / update (sensitive credentials are encrypted at rest)
curl -X PUT https://ns-myapp.example.com/v1/push/config \
  -H 'Authorization: Bearer <user-jwt>' \
  -H 'Content-Type: application/json' \
  -d '{
    "ntfy_base_url":   "https://ntfy.sh",
    "ntfy_auth_token": "tk_…"
  }'

# Read (sensitive fields redacted to booleans)
curl https://ns-myapp.example.com/v1/push/config \
  -H 'Authorization: Bearer <user-jwt>'

# Clear (push reverts to gateway-wide defaults if any, else 503)
curl -X DELETE https://ns-myapp.example.com/v1/push/config \
  -H 'Authorization: Bearer <user-jwt>'

Field semantics

FieldSensitive?Notes
ntfy_base_urlNoURL of the ntfy server. https://ntfy.sh works for testing.
ntfy_auth_tokenYesOptional bearer token sent to ntfy. Encrypted at rest.
expo_access_tokenYesExpo Push API access token. Encrypted at rest.

PUT semantics are field-level — a null (or omitted) field leaves the existing value alone; an explicit empty string clears just that field. To clear EVERYTHING use DELETE.

After a PUT the next push_send (host call) or POST /v1/push/send uses the new providers — the cached dispatcher is invalidated automatically.

If no per-namespace config is set AND the gateway has no YAML defaults, the push endpoints return 503 SERVICE_UNAVAILABLE with a message naming the exact config to set.

Managing Secrets

Secrets are encrypted at rest (AES-256-GCM) and stored in your namespace's own database. Functions read them via get_secret("name") at runtime.

The encryption key is derived from the cluster secret and is the same across the cluster, so what separates one namespace's secrets from another's is the database they are in, not a key only that namespace holds. A function cannot read them with SQL either: function_secrets is one of the tables a function's own SQL may not name.

CLI Commands

bash
# Set a secret (inline value)
orama function secrets set APNS_KEY_ID "ABC123DEF"

# Set a secret from a file (useful for PEM keys, certificates)
orama function secrets set APNS_AUTH_KEY --from-file ./AuthKey_ABC123.p8

# List all secret names (values are never shown)
orama function secrets list

# Delete a secret
orama function secrets delete APNS_KEY_ID

# Delete without confirmation
orama function secrets delete APNS_KEY_ID --force

How It Works

  1. +You set secrets via the CLI → encrypted and stored in the database
  2. +Functions read secrets at runtime via get_secret("name") → decrypted on demand
  3. +Namespace separation → a namespace's secrets are stored in that namespace's own database, so a function in namespace A has no route to namespace B's. What this is not is a separate key per namespace: the encryption key is derived once from the cluster secret and is the same across the cluster, so the separation is the database boundary, not cryptography. Anything holding the cluster secret can read any namespace's secrets

PubSub Triggers

Triggers let functions react to events automatically. When a message is published to a PubSub topic, all functions with a trigger on that topic are invoked asynchronously. Each publish fires a trigger once across the namespace's gateways: every gateway that subscribes to the topic receives the message, and the first to claim it in the namespace's Olric dispatches while the others skip. Two byte-identical publishes at the same trigger depth within 30 seconds count as one (the same bytes republished one level deeper by a triggered function are a new publish), and if Olric cannot be reached the claim is skipped and a gateway fires on its own, so a duplicate is possible then (the gateway logs PubSub dispatch dedup degraded).

CLI Commands

bash
# Add a trigger: invoke "call-push-handler" when messages hit "calls:invite"
orama function triggers add call-push-handler --topic calls:invite

# List triggers for a function
orama function triggers list call-push-handler

# Delete a trigger
orama function triggers delete call-push-handler <trigger-id>

Trigger Event Payload

When triggered via PubSub, the function receives this JSON via stdin:

jsonc
{
  "topic": "calls:invite",
  "data": { ... },
  "namespace": "my-namespace",
  "trigger_depth": 1,
  "timestamp": 1708972800
}

Depth Limiting

To prevent infinite loops (function A publishes to topic → triggers function A again), trigger depth is tracked. Maximum depth is 5. If a function's output triggers another function, trigger_depth increments. At depth 5, no further triggers fire.

The limit also holds when the function republishes over the network: a publish made by a function that was itself triggered carries the function's depth beside the message (a record keyed by namespace, topic and payload hash, kept for 30 seconds on the gateway and, when the namespace has Olric, shared across its gateways), and the dispatcher that receives the message continues the chain from that depth. The payload is never changed, so subscribers see exactly what was published. A record only raises a message's depth: a message nothing recorded, such as one a client publishes, starts at depth 0, and a tenant cannot lower the depth of a function's publish. A publish whose depth cannot be recorded fails rather than going out with the chain restarted.

Function Lifecycle

Versioning

Each deploy creates a new version (1, 2, 3, ...) and keeps the ones before it. The WASM binary is stored in IPFS (content-addressed) and metadata is stored in RQLite, one functions row per version, keyed (namespace, name, version).

bash
# List versions
orama function versions my-function

# Invoke a specific version
curl -X POST .../v1/functions/my-function@2/invoke
  • +The current version is the highest version that is active. Invoking, the WebSocket, function list and a trigger that fires all use it; name@N runs exactly version N and answers 404 function version not found when N does not exist, was pruned or is disabled.
  • +Triggers (cron, pub/sub, database) follow the current version: a deploy moves them to the new row.
  • +The last 10 versions are kept. A deploy past that removes the oldest; its invocation history stays readable under the function's name.
  • +delete without a version, disable and enable act on every version of the function. A deleted function's versions are no longer invocable, and the next deploy continues the numbering. A function deleted by a release before this one carries status inactive, the same status disable sets, so enable cannot tell it from a disabled function and brings it back; only deletes made with this release or later are protected from enable.

Invocation Logging

Every invocation is logged with: request ID, duration, status (success/error/timeout), input/output size, and any log_info/log_error messages.

bash
orama function logs my-function

logs and versions of a function that does not exist answer 404, which the CLI exits with 4 (not found); a function that exists and has not run has an empty log and a 200.

CLI Reference

Every flag of every command is in the CLI reference, which is generated from the command tree.

CommandDescription
orama function init <name>Scaffold a new function project
orama function build [dir]Compile Go to WASM
orama function deploy [dir]Deploy WASM to the network
orama function invoke <name> --data <json>Invoke a function
orama function listList deployed functions
orama function get <name>Get function details
orama function delete <name>Delete a function
orama function logs <name>View invocation logs
orama function versions <name>List function versions
orama function disable <name>Stop serving a function without deleting it
orama function enable <name>Serve a previously disabled function again (a deleted function stays deleted: 404)
orama function secrets set <name> <value>Set an encrypted secret
orama function secrets listList secret names
orama function secrets delete <name>Delete a secret
orama function triggers add <fn> --topic <t>Add PubSub trigger
orama function triggers list <fn>List triggers
orama function triggers delete <fn> <id>Delete a trigger

HTTP API Reference

MethodEndpointDescription
POST/v1/functionsDeploy function (multipart/form-data)
GET/v1/functionsList functions
GET/v1/functions/{name}Get function info
DELETE/v1/functions/{name}Delete function
POST/v1/functions/{name}/invokeInvoke function
GET/v1/functions/{name}/versionsList versions
GET/v1/functions/{name}/logsGet logs
WS/v1/functions/{name}/wsWebSocket invoke (streaming)
PUT/v1/functions/secretsSet a secret
GET/v1/functions/secretsList secret names
DELETE/v1/functions/secrets/{name}Delete a secret
POST/v1/functions/{name}/triggersAdd PubSub trigger
GET/v1/functions/{name}/triggersList triggers
DELETE/v1/functions/{name}/triggers/{id}Delete trigger
POST/v1/invoke/{namespace}/{name}Direct invoke (alt endpoint)

Every route except invoke acts on the namespace of the credential that calls it. A request that names a different namespace — the deploy metadata's or form's namespace, ?namespace=, or an X-Namespace header — is refused with 403 rather than acted on. POST /v1/functions/{name}/invoke runs the function of the namespace named by ?namespace=, otherwise the credential's; an anonymous call that names neither is refused with 400. A public function is invocable without a credential, but the request has to say whose it is.

A page served from a name under the cluster's base domain may call a function from the browser, through the main gateway or the namespace gateway (https://ns-<namespace>.<base domain>): the answer carries one Access-Control-Allow-Origin, the page's origin, and Vary: Origin. The CORS policy, Strict-Transport-Security and X-Content-Type-Options are the platform's: a function's own values for them are not passed on.

Invoke rate limits

Each gateway holds token buckets for invokes, checked in this order, and the first to refuse answers 429 RATE_LIMITED with scope= and retry_after= in the message and a Retry-After header:

ScopeKeySustainedBurst
per_function_walletnamespace, function, walletthe function's own limit, only when it declares oneits own burst
per_walletnamespace, wallet600 a minute (10 a second)60
per_ipnamespace, address (callers with no wallet)120 a minute (2 a second)30
per_namespacenamespace250,000 a minute (about 4,166 a second)6,000

An invoke is charged once, by the gateway that runs it; the hop that proxies a request to a namespace gateway is not charged. The buckets are per gateway, not shared across a namespace's gateways. A caller that sends 100 invokes at 30 a second from one wallet spends the 60-invoke burst in about two seconds and then gets 429s at the 10-a-second refill (the per_wallet scope).

WebSockets

/v1/functions/{name}/ws runs a function over a WebSocket. With ws_persistent: true in function.yaml one WASM instance is bound to the socket for its lifetime (ws_open / ws_frame / ws_close); otherwise every frame is a separate invocation.

The socket is authorized once, at the upgrade: Authorization: Bearer <jwt>, or ?jwt=<token> where a client cannot set a header. Every frame then runs as the identity the upgrade established, so a socket is held to the token that opened it for as long as it stays open:

  • +

    The gateway re-checks every open socket every 10 seconds. One whose token was revoked — the token, its session, or its subject — is closed with 4403; sign in again. One whose token expired more than two minutes ago is closed with 4401; reconnect with a fresh token. A persistent socket also refuses an application frame past that point without waiting for the next check.

  • +

    A persistent socket stays open across token rotation with a control frame, answered with an __orama_ack:

    jsonl
    {"__orama":"auth.refresh","jwt":"<new token>"}
    {"__orama_ack":"auth.refresh","ok":true,"subject":"<wallet>"}

    The new token must be for the same subject, namespace and device the socket was opened with; any other is refused with ok:false and the socket keeps its current token. A socket opened with an API key, or with no credential on a public function, has no token and cannot take one on — reconnect instead.

  • +

    A socket opened with an API key is not re-checked; revoking the key refuses its next upgrade.

A stateless function socket and a /v1/pubsub/ws subscription are re-checked the same way. Neither takes auth.refresh, so either one is closed with 4401 two minutes after its token expires and has to reconnect with a fresh one. See the technical reference's identity chapter (docs/whitepaper/technical-reference/vol1/13-identity.md, "Open WebSockets").

Storage fetch capabilities

A function running for a device-bound session can mint the tokens a correspondent downloads one stored object with, with no identity (the technical reference's storage chapter (docs/whitepaper/technical-reference/vol1/19-storage.md, "Fetch capabilities")):

FunctionDescription
storage_fetch_cap_mint(cid, count, ttl_seconds) → stringMints count (1 to 64) fetch capabilities to read cid of this namespace, issued by the calling session's device, for 1 hour to 7 days. Returns JSON {"namespace","cid","caps":[{"id","token","revoke_key","expires_at"}]} (expires_at in Unix seconds), the body POST /v1/storage/fetch-caps answers, or empty on failure (the gateway log says why): no device-bound caller, a cid not in canonical form, a count or ttl out of range, or a gateway without a cluster secret. Keep revoke_key with the id: revoking by id takes it. Neither ownership of cid nor a storage selector is checked here (the HTTP mint checks both because its caller is a credential that may be narrowed to storage:avatars/*): a function is the namespace's own code, and which of the namespace's objects a caller may read is the application's decision. This is the only storage access a function has — it cannot read or write IPFS itself (see Storage) — and it reaches only the namespace's own objects: the serving gateway asks ownership on every use, so a token for a CID the namespace does not own opens nothing. Signature: (cid_ptr, cid_len, count i32, ttl_seconds i64) → i64, the packed ptr<<32 | len.

Capabilities

A function that declares ws_auth: capability may also have its socket opened with a capability instead of a credential:

GET /v1/functions/rpc-router/ws?namespace=anchat&cap=<token>

The function mints capabilities for its own socket, from a call made by a device-bound session (see the technical reference's identity chapter (docs/whitepaper/technical-reference/vol1/13-identity.md, "Open WebSockets")):

FunctionDescription
capability_mint(resource, ttl_seconds) → stringMints a capability that opens this function's socket, naming resource (up to 256 bytes, chosen by the application — a mailbox id), issued by the calling session's device, for 60 seconds to 7 days. Returns JSON {"token","cap_id","resource","issuer_device","expires_at"}, or empty on failure (the gateway log says why): no device-bound caller, a ttl out of range, or a gateway without a cluster secret. Signature: (res_ptr, res_len i32, ttl_seconds i64) → i64, the packed ptr<<32 | len.
capability_revoke(token) → i32Refuses one capability of this namespace from now on, named by the token capability_mint returned (an id alone is not accepted: only a capability the namespace was issued can be revoked); the sockets it opened are closed with 4403 within 10 seconds. 1 on success, including for one already revoked or expired; 0 for a token that is not this namespace's. Revoking the device that issued a capability refuses it too. Signature: (token_ptr, token_len i32) → i32.
get_caller_capability() → stringSee Context.

A socket opened with a capability reports no caller: get_caller_wallet, get_caller_jwt_subject, get_caller_device_id and every claim are empty, and get_caller_capability says what the capability grants. It cannot take auth.refresh, and it is closed with 4401 two minutes after the capability expires. A nested function_invoke from it runs with no caller, so it reaches only public functions. A capability opens the live version of the function (name@N is refused), and not while the function is disabled; one capability holds at most 16 sockets on a gateway at once. What the gateway checks, what it still learns, and the rate limit on these upgrades are in the technical reference's identity chapter (docs/whitepaper/technical-reference/vol1/13-identity.md, "Open WebSockets").

WebRTC rooms

A namespace with WebRTC enabled can decide who joins its rooms and remove or mute a participant from a function (WebRTC). These act on the calling function's own namespace and on no other; the room, the user and the device are the function's to name.

FunctionDescription
webrtc_admit(room, user, device, ttl_seconds) → stringAdmits user (the authenticated subject they will join as) to room for ttl_seconds (1 to 86400), from any device when device is empty or from that device only. A namespace that requires admission lets in only users admitted this way. Admitting again extends the admission and lifts a revocation. Returns JSON {"room","user_id","device_id","expires_at"} (unix seconds), or empty on failure (the gateway log says why): an invalid room, a user or device over 256 bytes, a ttl out of range, or a gateway with no WebRTC set up. Signature: (room_ptr, room_len, user_ptr, user_len, device_ptr, device_len i32, ttl_seconds i64) → i64, the packed ptr<<32 | len.
webrtc_kick(room, user) → i32Revokes every admission of user to room and closes their connection on every SFU of the namespace; the rejoin is refused until they are admitted again. 1 when both were done, 0 otherwise: when any SFU could not be told, the revocation still stands (the log says so) and repeating the call is safe. Signature: (room_ptr, room_len, user_ptr, user_len i32) → i32.
webrtc_mute(room, user, muted) → i32Stops (muted non-zero) or resumes the forwarding of user's audio in room, on every SFU of the namespace, so a modified client that keeps sending is still silent. Recorded on their admissions, so it holds when they rejoin (for 7 days after the admission expires), and logged by each SFU so a ticket issued before it cannot undo it. 1 on success, 0 on failure. Signature: (room_ptr, room_len, user_ptr, user_len, muted i32) → i32.
go
//go:wasmimport env webrtc_admit
func webrtcAdmit(roomPtr, roomLen, userPtr, userLen, devPtr, devLen uint32, ttlSeconds int64) uint64

// admit the caller to the call they were invited to, for ten minutes
admitted := unpack(webrtcAdmit(ptr(room), uint32(len(room)), ptr(user), uint32(len(user)), 0, 0, 600))

A single invocation (or persistent-socket frame) may make at most 100 webrtc_admit, webrtc_kick and webrtc_mute calls together, because each writes the namespace's database and a kick or mute calls every SFU; the 101st fails (0, and the gateway log says the budget is exceeded), as pubsub_publish does past 1000.

Who joined and left is published by the platform on _orama/webrtc/<room>; a pubsub trigger on _orama/webrtc/* runs a function for each.

Invoking from application code

The TypeScript SDK calls the direct-invoke endpoint for you:

typescript
import { createClient } from "@debros/orama";

const client = createClient({
  baseURL: "https://ns-myapp.cluster.example.com",
  apiKey: process.env.ORAMA_API_KEY,
  functionsConfig: { namespace: "myapp" },
});

const result = await client.functions.invoke("my-function", { name: "World" });

A function whose output is JSON — any JSON value, null, a number or a string as much as an object — is answered with that output as it is (Content-Type: application/json). Output that is not JSON is wrapped: {"request_id", "output", "status", "duration_ms"}. An anonymous invocation on ns-<name> runs in that namespace; on the cluster's own host it must name one (/v1/invoke/<namespace>/<function>).

A deployed app invokes functions with its own workload token (Access control). What it may run is its grant at the moment of the call, not the scopes its token was minted with, so narrowing or revoking the grant takes effect on a token already issued. An app holds nothing until orama app grants set <app> runtime gives it something; a 403 FORBIDDEN means the grant does not cover the function, and 401 means no identity. It is never an admin on invoke, so admin-only internal functions stay closed to it, and its token is good in its own namespace only: a function of another namespace is refused 403.

Invoking needs the invoke grant, which the invoke-only and app-runtime key profiles both carry; deploying, secrets and triggers are control-plane and need an admin key. See SDK Reference, and Go Client SDK for the Go client.

Example: Call Push Handler

A real-world function that sends VoIP push notifications when a call invite is published to PubSub:

yaml
# function.yaml
name: call-push-handler
memory: 128
timeout: 30
go
// function.go — triggered by PubSub on "calls:invite"
package main

import (
    "encoding/json"
    "os"
)

// This function:
// 1. Receives a call invite event from PubSub trigger
// 2. Queries the database for the callee's device info
// 3. Reads push notification credentials from secrets
// 4. Sends a push notification via http_fetch

func main() {
    // Read PubSub trigger event from stdin
    var input []byte
    buf := make([]byte, 4096)
    for {
        n, err := os.Stdin.Read(buf)
        if n > 0 {
            input = append(input, buf[:n]...)
        }
        if err != nil {
            break
        }
    }

    // Parse the trigger event wrapper
    var event struct {
        Topic string          `json:"topic"`
        Data  json.RawMessage `json:"data"`
    }
    json.Unmarshal(input, &event)

    // Parse the actual call invite data
    var invite struct {
        CalleeID   string `json:"calleeId"`
        CallerName string `json:"callerName"`
        CallType   string `json:"callType"`
    }
    json.Unmarshal(event.Data, &invite)

    // At this point, the function would use host functions:
    //
    // 1. db_query("SELECT push_token, device_type FROM devices WHERE user_id = ?",
    //             json.Marshal([]string{invite.CalleeID}))
    //
    // 2. get_secret("FCM_SERVER_KEY") for Android push
    //    get_secret("APNS_KEY_PEM") for iOS push
    //
    // 3. http_fetch("POST", "https://fcm.googleapis.com/v1/...", headers, body)
    //
    // 4. log_info("Push sent to " + invite.CalleeID)
    //
    // Note: Host functions use the WASM ABI (pointer/length).
    // The sdk/fn package wraps them for ergonomic access.

    response := map[string]interface{}{
        "status":  "sent",
        "callee":  invite.CalleeID,
    }
    output, _ := json.Marshal(response)
    os.Stdout.Write(output)
}

Deploy and wire the trigger:

bash
orama function build
orama function deploy

# Set push notification secrets
orama function secrets set FCM_SERVER_KEY "your-fcm-key"
orama function secrets set APNS_KEY_PEM --from-file ./AuthKey.p8
orama function secrets set APNS_KEY_ID "ABC123"
orama function secrets set APNS_TEAM_ID "TEAM456"

# Wire the PubSub trigger
orama function triggers add call-push-handler --topic calls:invite