Functions, background jobs, and cron
Functions are Deno-compatible TypeScript declared in opencloud.yaml.
functions:
- name: summarize
entrypoint: functions/summarize/index.ts
access: userUse user for a signed-in caller, public for optional identity, and system only for cron, queue-consumer, inbound-email, or other platform invocation. Queue consumers must be system Functions. The browser SDK selects auth automatically and rejects browser calls to system Functions.
Define a Function
Every Function uses one declarative form:
import { defineFunction, errors, schema } from "@opencloud/server";
export default defineFunction({
input: schema.object({ itemId: schema.uuid() }),
handler: async ({ input, user, data, log, requestId, environment }) => {
if (!user) {
throw errors.unauthorized("SIGN_IN_REQUIRED", "Please sign in");
}
const item = await data.table("items").getById(input.itemId, {
select: ["id", "title"],
});
if (!item) throw errors.notFound("ITEM_NOT_FOUND", "Item not found");
log.info("item loaded", { itemId: input.itemId });
return { item, requestId, environment };
},
});defineFunction accepts exactly { input, handler }. Input is parsed before the handler runs. Body-reader methods and handler-only definitions do not exist. Schema helpers are string, uuid, number, boolean, literal, enum, array, object, optional, and nullable; objects reject unknown fields.
The handler context contains exactly:
| Capability | Purpose |
|---|---|
input | Already validated input value |
http | SDK 2.3.0: read-only route method, pathname, params and query; null for non-route calls |
user | Signed-in { id } or null |
job | Queue invocation metadata { id, queue, attempt }, or null |
data | Bounded table operations; every table uses primary key id |
files | One-object managed-file operations with opaque FileRef values |
ai | generateText, streamText, generateObject, and generateImage |
email | Send from manifest-declared application aliases |
notifications | Send visible Web Push notifications to subscribed app users |
jobs | Enqueue declared background work and inspect its status |
integrations | Brokered provider clients resolved from manifest slots and caller identity |
secrets | get and require declared secrets |
log | Structured debug, info, warn, and error logs |
requestId, environment | Correlation and dev/production metadata |
To invoke a Function from an app URL such as /pixel.jpg?param1=test123, declare a schema-3 Function route and pin SDK 2.3.0. Read the value with http?.query.get("param1"). Query and path parameters remain separate from schema-validated JSON input; GET/HEAD input is {}. Function access and private-app admission still apply. HEAD runs the handler without returning response bytes, so avoid write side effects in GET/HEAD handlers.
For a Function invoked with a custom-domain browser session, data retains the invocation's original short-lived authorization, which expires within 60 seconds. Data calls made after expiry fail even if the Function is still running within its normal execution timeout. The Function does not refresh that authorization automatically. Keep interactive database work short; use a separately declared system queue consumer for longer background work with its own explicit RLS policy.
PostgreSQL search
The unpublished paired SDK 2.4/platform candidate adds data.search to the Function context. Declare a named search under data.search, then query it:
const result = await data.search("document-chunks", {
mode: "hybrid",
query: input.question,
vector: queryEmbedding,
model: "potion-128-v1",
where: { document_id: input.documentId },
limit: 10,
});fullText requires query; vector requires vector and model; hybrid requires all three. Irrelevant mode fields are rejected. Query text is trimmed, nonempty and at most 2,048 characters. Vectors contain finite float32-compatible values, have nonzero norm and exactly match declared dimensions. The model identity must match exactly. where supports only declared scalar equality filters, with null meaning IS NULL; omit it for no additional filter. limit defaults to 10 and accepts 1–50.
The result is { hits: [...] }. Each hit contains record with only the declared selected columns and separate ranking evidence:
| Field | Meaning |
|---|---|
fullTextScore, fullTextRank | ts_rank_cd score and one-based lexical candidate rank |
vectorDistance, vectorSimilarity, vectorRank | Exact cosine distance, 1 - distance, and one-based semantic candidate rank |
fusionScore | Hybrid sum of 1 / (60 + componentRank) for present components |
Each component takes at most 100 candidates after RLS and filters. Results use ID ordering to break ties. Full-text parsing uses websearch_to_tsquery with the declared language; an empty parsed query has no lexical candidates. Hybrid may still return semantic matches. Missing component evidence and single-mode fusion scores are null. Cosine similarity is not confidence; show the actual passage and scores without claiming individual highlighted words caused an embedding match. Exact vector ranking remains exact when an HNSW index exists; approximate acceleration is outside this initial contract.
Search uses the same invocation Authorization, app/dev schema and RLS as ordinary Data calls. It does not impersonate an owner or bypass policies. System Functions retain their system identity and require explicit app RLS. This is a supported Function SDK API; equivalent-authority raw REST is not separately prohibited by the search capability. The browser SDK has no search method: call a declared Function through opencloud.functions.call.
Records are limited to 16 KiB each and responses to 1 MiB; excessive results fail rather than silently truncating evidence. SEARCH_NOT_DECLARED, SEARCH_INPUT_INVALID, SEARCH_MODEL_MISMATCH, SEARCH_DIMENSION_MISMATCH and SEARCH_RESULT_TOO_LARGE are nonretryable. Temporary database, schema-cache or timeout failures use retryable SEARCH_UNAVAILABLE.
Store embeddings from one declared model space and re-embed when it changes. For background document ingestion, stage chunks and publish them atomically through a current document revision; RLS should hide incomplete and obsolete revisions. Direct Files changes do not automatically invalidate app-owned derived rows. See Search columns and indexes.
Managed files
Function and browser Files share the same input shape and references:
const file = await files.upload({
data: pdfBytes,
name: "report.pdf",
contentType: "application/pdf",
});
const download = await files.download(file); // download.data is Uint8Array
const replacement = await files.replace(file, {
data: replacementBytes,
name: "report.pdf",
});
await files.remove(replacement);Retries, idempotency, timeouts, grants, buckets, and object paths are private SDK behavior.
AI
The platform selects the model. Choose the desired output shape:
const text = await ai.generateText({
instructions: "Return one concise summary.",
prompt: item.title,
});
const result = await ai.generateObject({
prompt: item.title,
schema: schema.object({ summary: schema.string(), urgent: schema.boolean() }),
});
const receipt = await files.info(input.receiptFileId);
if (!["image/png", "image/jpeg", "image/webp", "image/gif"].includes(receipt.contentType)) {
throw errors.badRequest("UNSUPPORTED_RECEIPT", "Receipt must be an image");
}
const receiptResult = await ai.generateObject({
prompt: "Read this receipt and return its total.",
attachments: [{
type: "file",
file: receipt,
detail: "original",
}],
schema: schema.object({ total: schema.string() }),
});
const image = await ai.generateImage({
prompt: "A friendly dodo reading a book, flat illustration",
});
await files.upload({
data: image.data,
name: "dodo.png",
contentType: image.contentType,
});Text, stream, and object generation accept up to four PNG, JPEG, WEBP, or non-animated GIF attachments (15 MiB each), or one managed PDF of at most 20 pages and 20 MiB; all attachments share a 20 MiB total. Prefer { type: "file", file: fileRef } for managed Files; the platform retrieves the content under the current invocation's Files authority. Use { type: "image", data, contentType } for generated or in-memory bytes. Remote image URLs are not accepted. Use detail: "original" for receipt OCR and other small text. PDFs must use a managed File reference. Image generation returns PNG bytes and an optional revised prompt. Raw chat or image envelopes, provider model names, URLs, keys, and HTTP responses are not exposed.
Errors
Expected failures use errors.badRequest, unauthorized, forbidden, notFound, conflict, or unavailable, each with (code, message, details?). The platform catches all imports, rejections, timeouts, invalid responses, and platform-call failures. Unknown production errors remain generic.
When a Function must report its pinned SDK version, import and return the OPEN_CLOUD_SDK_VERSION constant from @opencloud/server; never hard-code the version string.
Call and test
const result = await opencloud.functions.call("summarize", { itemId });Use functions.stream only when the Function intentionally returns streaming bytes. In development, exercise every declared Function through its intended path after the final revision: direct invocation or browser action for ordinary Functions, enqueue for queue consumers, and synthetic injection for inbound email.
Background jobs
With SDK 2.4.0, an authenticated producer may delegate download access to specific private files when enqueueing work:
await jobs.enqueue('index-document', { documentId, fileId }, {
idempotencyKey: `index:${documentId}:${revisionId}`,
files: { read: [fileId] },
});
// Inside the declared system consumer:
const pdf = await files.download(input.fileId);The producer must currently be allowed to read every selected file. The worker receives only download permission for those inputs; files.info, mutations and further delegation are not included. Its user and database identity remain system-scoped, so application tables still need explicit auth.is_system() policies. Keep files.access: user for private documents.
Select 1–16 files totaling at most 128 MiB, within the existing per-file limits. The grant pins each file's content generation: replacing it, even with identical bytes, produces FILE_INPUT_CHANGED. Deletion or loss of current access denies future reads. Enqueue a new job with a new idempotency key for new input; replay does not refresh a grant. Grants expire after 14 days and retries receive fresh attempt authority. Normal browser sign-out does not cancel durable delegation. The selected consumer Function name cannot be changed for that delegated job.
Declare a queue and its system-only consumer in the manifest:
functions:
- name: submit-report
entrypoint: functions/submit-report/index.ts
access: user
- name: generate-report
entrypoint: functions/generate-report/index.ts
access: system
queues:
- name: reports
function: generate-report
concurrency: 2
maxAttempts: 3
retryDelaySeconds: 5
retryBackoff: true
timeoutSeconds: 120A producer Function enqueues a JSON object. Every enqueue requires a stable idempotency key so a retried HTTP request cannot create duplicate work:
const job = await jobs.enqueue(
"reports",
{ reportId: input.reportId },
{
idempotencyKey: `report:${input.reportId}`,
delaySeconds: 0,
},
);Idempotency is scoped to the app, environment, development namespace, and queue. Repeating the same key and input returns the original job; reusing that key with different input returns IDEMPOTENCY_KEY_REUSED. The same key may be used independently in another declared queue.
The consumer receives the queued object as input and delivery metadata as job:
export default defineFunction({
input: schema.object({ reportId: schema.uuid() }),
handler: async ({ input, job, data }) => {
if (!job) {
throw errors.forbidden("JOB_REQUIRED", "Queue invocation required");
}
const reports = data.table("reports");
const report = await reports.getById(input.reportId);
if (report?.status === "complete") return { duplicate: true };
await reports.updateById(input.reportId, {
status: "complete",
completed_attempt: job.attempt,
});
return { duplicate: false };
},
});Trusted system invocations use a short-lived database identity scoped to the current app, while context.user and auth.uid() remain null. If a system consumer must read or update an app table, make that path explicit in its RLS policy with auth.is_system(), for example owner_id = auth.uid() or auth.is_system(). Do not use auth.uid() is null as a substitute: that would also admit ordinary anonymous access in a public app. OpenCloud's restrictive app boundary still prevents the system identity from crossing into another app or development namespace.
Delivery is at-least-once. Make database and external side effects idempotent; job.id is stable across attempts. Throw errors.unavailable(...) for a retryable application failure. Other expected errors are terminal. After the configured attempts, the broker marks the job dead_lettered. The initial delivery counts as attempt 1, and backoff applies between later attempts.
jobs.get(job.id) returns queued, running, retry_wait, succeeded, or dead_lettered, plus timestamps and the latest bounded error. Queue payloads are JSON objects up to 64 KiB. Delay is optional and bounded to seven days. Terminal broker and status records are retained for 14 days. An app can retain at most 10,000 active jobs per production or isolated development namespace.
Queue concurrency is enforced per app, environment, development namespace, and logical queue. Retry and concurrency policy is pinned when work is enqueued; the consumer target resolves from the currently active declaration when work is dispatched, so keep consumer input compatible while older jobs may remain. A suspended, archived, or transiently non-active production app pauses delivery by rescheduling work without consuming a logical attempt. Removing a queue declaration causes its pending jobs to terminate as dead_lettered with QUEUE_NOT_ACTIVE; restoring the queue does not redrive them.
The producer's optional timeoutMs limits only its broker HTTP request. The manifest's timeoutSeconds limits each consumer attempt. See Troubleshooting for enqueue and delivery error codes. There is no strict FIFO guarantee, priority API, manual acknowledgement, cancellation, or dead-letter redrive API in this first release.
Observe background jobs
The app dashboard has a separate Background jobs section with retained created, retried, succeeded, and failed totals, current depth, per-queue policy, and recent safe execution metadata. Its inclusive Created from and Created to date-time selectors use the browser's local time and apply to the totals, queue rollups, and history together. History uses cursor-backed Previous/Next pagination with selectable 10, 25, 50, or 100 row pages. The Agent Feed exposes jobs.created, jobs.retried, jobs.succeeded, jobs.failed, and jobs.active signals plus job failure events and a built-in background-job failure alert.
Owner-authorized clients can use GET /v1/apps/{appId}/jobs and GET /v1/apps/{appId}/jobs/{jobId}. The full hosted MCP surface provides the equivalent list_background_jobs and get_background_job tools; the focused builder surface omits production diagnostics. List callers may pass inclusive ISO 8601 from and to creation times, limit, and the returned nextCursor; queue and state filters additionally narrow history rows. These interfaces never return payloads, idempotency keys, or enqueuing user identifiers, and they do not offer cancel or redrive mutations.
Cron
The Function receives { source: "opencloud.cron", name, scheduledAt }. Its strict schema must accept the full envelope, including manually triggered cron invocations:
input: schema.object({
source: schema.literal("opencloud.cron"),
name: schema.string(),
scheduledAt: schema.string(),
}),Use name and scheduledAt in the occurrence's idempotency key. An empty object schema rejects the payload with HTTP 400 before the handler runs.
functions:
- name: hourly-maintenance
entrypoint: functions/hourly-maintenance/index.ts
access: system
cron:
- name: hourly-maintenance
schedule: "0 * * * *"
function: hourly-maintenance
enabled: trueCron uses five-field expressions with UTC as the default. Set optional timezone: Europe/Prague for a named IANA timezone. Daily schedules with an explicit timezone (or an app task declaration) run once per local date: a nonexistent DST time moves to the first valid minute, a repeated time runs once, and recovery coalesces missed runs to the latest occurrence within 24 hours. Queue capacity can delay execution beyond the scheduled time.
Verification invokes the active cron deterministically rather than waiting for its cadence.
Declare secret names in the manifest and provision their values separately. Never return or log plaintext secrets.
