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
# 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-functionProject 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
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:
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 + "!",
})
})
}| Function | Description |
|---|---|
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)
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:
# Using the CLI (recommended) orama function build # Or manually tinygo build -o function.wasm -target wasi function.go
⚠️
orama function deployreuses an existingfunction.wasm. It only auto-builds whenfunction.wasmis absent. If you editfunction.gobut a stalefunction.wasmfrom a previous build is still on disk,deployuploads the old binary and your changes silently never ship — which looks exactly like "the gateway isn't picking up my update". After editing source, runorama function build(which overwrites the wasm) orrm function.wasmbeforeorama 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 deploytargets the gateway of your active CLI environment (orama network); point it at the namespace gateway (orama network add <name> https://ns-<ns>.<domain>thenorama network use <name>), or setORAMA_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 thedefaultnamespace'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 athttps://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_URLandORAMA_GATEWAYall 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:
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
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 name | Status | Use |
|---|---|---|
env | canonical | Recommended for new code. Matches the WASI / TinyGo convention used by every example in this doc and the sdk/fn package. |
host | alias (kept) | Long-standing alternative; supported indefinitely. |
orama | alias (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: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
| Function | Description |
|---|---|
get_caller_wallet() → string | Resolved caller wallet (JWT subject if Bearer auth, else namespace pseudo-id when API-key auth). |
get_caller_jwt_subject() → string | JWT 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() → string | The 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() → string | What 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) → string | Custom JWT claim by name (tier, subscription, etc.). Empty if missing or non-JWT request. |
get_request_id() → string | Unique invocation ID |
get_env(key) → string | Environment variable from function.yaml |
get_secret(name) → string | Decrypted secret value (see Managing Secrets) |
Database (RQLite)
| Function | Description |
|---|---|
db_query_v2(sql, argsJSON) → JSON | Recommended. 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) → JSON | Recommended. 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) → JSON | Legacy. Execute SELECT, returns JSON array of rows. No way to surface query errors — prefer db_query_v2. |
db_execute(sql, argsJSON) → int | Legacy. Returns affected rows ONLY. Returns 0 for both "0 rows" and "SQL error" — caller can't distinguish. Prefer db_execute_v2. |
db_transaction(opsJSON) → JSON | Atomic batch — see "Database Transactions" below. |
Example v2 usage from WASM:
//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:
{
"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— everyexecop is durable. - +
committed: falsewithfailed_indexset — one statement failed and the whole batch rolled back; that op'sresultsentry carries its SQL error. - +
errornon-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.codeclassifies 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:
| Code | Meaning | Retry? |
|---|---|---|
CONSTRAINT_VIOLATION | A 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_STATEMENTS | Over the per-batch statement cap | No — split the batch |
PAYLOAD_TOO_LARGE | Result rows/bytes over the caps | No — paginate |
DEADLINE_EXCEEDED | The call ran past its deadline | Yes |
UNAVAILABLE | Database unreachable, or leader lost mid-call | Yes |
INVALID_ARGUMENT | Malformed request (bad JSON, unknown op kind) | No — fix the caller |
INTERNAL | Unclassified — 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 names | Depends; 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
execops go to RQLite in one transactional request. Any failure rolls back the entire batch. - +
queryops 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 ownresultsentry getserrorandcommittedstaystrue. - +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:
| Limit | Value | Applies to |
|---|---|---|
| Statements per batch | 100 | db_transaction, db_query_batch, exec_and_publish |
| Rows returned per query op | 10,000 | db_query_batch |
| Total result bytes per batch | 32 MiB | db_query_batch |
| Batch round-trip deadline | 10s | db_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.
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)
| Function | Description |
|---|---|
cache_get(key) → bytes | Get 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) → u32 | Remove 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) → int64 | Atomically increment by 1 (init to 0 if missing). Returns the new value, or 0 on failure. |
cache_incr_by(key, delta) → int64 | Atomically 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:
nincrements of 1 on a new key return1..n, each once. - +An increment never sets or changes an expiry. A counter an increment created
has none (clear it with
cache_delete); a countercache_setstored with attlkeeps that deadline through increments, and once it expires the next increment starts again from 0. To count in a window,cache_setthe key with the window as itsttlfirst; do not expectcache_incrto start the clock. - +A value that is missing, expired or not a number counts as 0 and is replaced
by the result.
cache_geton 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_UNAVAILABLEfrom 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
| Function | Description |
|---|---|
http_fetch(method, url, headersJSON, body) → JSON | Make 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) → JSON | http_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) → JSON | Deprecated 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 viahttp_fetchwith 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
| Function | Description |
|---|---|
pubsub_publish(topic, dataJSON) → bool | Publish message to a PubSub topic. Returns true on success. |
Push
| Function | Description |
|---|---|
push_send(userID, msgJSON) → u32 | Push 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) → u64 | Same, 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) → u64 | Push 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: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.
| Function | Description |
|---|---|
ephemeral_state_set(topic, key, payload, ttlMs) → u32 | Record 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) → u32 | Clear state this client owns; publishes ephemeral.clear (reason explicit). Idempotent — clearing a missing/non-owned key returns 1. |
ephemeral_state_list(topic) → u64 | Reconnect 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: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:
{"_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:
{"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):
// 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:
- +
_oramais reserved. A payload that is a JSON object with a top-level_oramakey (any letter case) is refused onPOST /v1/pubsub/publishand/v1/pubsub/publish-batchfor every caller, the namespace owner included:400PUBSUB_RESERVED_KEY, and a batch is refused whole, naming the item. The key marks events the platform publishes itself (ephemeral.set,ephemeral.clearabove), 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 refused400PUBSUB_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_bridgemay 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
topicfield included, is what the publisher chose to claim. Open the function WebSocket with?pubsub_delivery=stampedand every bridged message arrives instead asjson{"_orama":"pubsub.message","topic":"chat.general","data_base64":"<publisher bytes, base64>"}topicis the topic the platform delivered the message on, and the payload is untouched insidedata_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-decodedata_base64and dispatch ontopicfrom the envelope, not from the payload. Platform events (ephemeral.*) arrive insidedata_base64like any other message. The option applies to both the stateless and the persistent function WebSocket.
Logging
| Function | Description |
|---|---|
log_info(message) | Log info-level message (captured in invocation logs). |
log_error(message) | Log error-level message. |
Other host functions
| Function | Description |
|---|---|
get_ws_client_id() → string | The 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) → bytes | Invoke 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() → JSON | Short-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:
# 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
| Field | Sensitive? | Notes |
|---|---|---|
ntfy_base_url | No | URL of the ntfy server. https://ntfy.sh works for testing. |
ntfy_auth_token | Yes | Optional bearer token sent to ntfy. Encrypted at rest. |
expo_access_token | Yes | Expo 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
# 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
- +You set secrets via the CLI → encrypted and stored in the database
- +Functions read secrets at runtime via
get_secret("name")→ decrypted on demand - +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
# 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:
{
"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).
# 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 listand a trigger that fires all use it;name@Nruns exactly version N and answers 404function version not foundwhen 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.
- +
deletewithout a version,disableandenableact 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 statusinactive, the same statusdisablesets, soenablecannot tell it from a disabled function and brings it back; only deletes made with this release or later are protected fromenable.
Invocation Logging
Every invocation is logged with: request ID, duration, status (success/error/timeout), input/output size, and any log_info/log_error messages.
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.
| Command | Description |
|---|---|
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 list | List 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 list | List 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
| Method | Endpoint | Description |
|---|---|---|
| POST | /v1/functions | Deploy function (multipart/form-data) |
| GET | /v1/functions | List functions |
| GET | /v1/functions/{name} | Get function info |
| DELETE | /v1/functions/{name} | Delete function |
| POST | /v1/functions/{name}/invoke | Invoke function |
| GET | /v1/functions/{name}/versions | List versions |
| GET | /v1/functions/{name}/logs | Get logs |
| WS | /v1/functions/{name}/ws | WebSocket invoke (streaming) |
| PUT | /v1/functions/secrets | Set a secret |
| GET | /v1/functions/secrets | List secret names |
| DELETE | /v1/functions/secrets/{name} | Delete a secret |
| POST | /v1/functions/{name}/triggers | Add PubSub trigger |
| GET | /v1/functions/{name}/triggers | List 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:
| Scope | Key | Sustained | Burst |
|---|---|---|---|
per_function_wallet | namespace, function, wallet | the function's own limit, only when it declares one | its own burst |
per_wallet | namespace, wallet | 600 a minute (10 a second) | 60 |
per_ip | namespace, address (callers with no wallet) | 120 a minute (2 a second) | 30 |
per_namespace | namespace | 250,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 with4401; 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:falseand 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")):
| Function | Description |
|---|---|
storage_fetch_cap_mint(cid, count, ttl_seconds) → string | Mints 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")):
| Function | Description |
|---|---|
capability_mint(resource, ttl_seconds) → string | Mints 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) → i32 | Refuses 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() → string | See 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.
| Function | Description |
|---|---|
webrtc_admit(room, user, device, ttl_seconds) → string | Admits 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) → i32 | Revokes 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) → i32 | Stops (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: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:
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:
# function.yaml name: call-push-handler memory: 128 timeout: 30
// 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:
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