agentleFS
buildpad-aibuildpad-ai/skills/create-cron/SKILL.md

create-cron

Skill1 starsChanged 27 days agoMITView on GitHub

Create and manage DaaS cron jobs for scheduled background tasks. Sets up recurring jobs with cron expressions, sandboxed JS code, timezone support, execution history, and overlap protection. Use when the user needs scheduled tasks, recurring jobs, background processing, or timed automation.

  • Reads credentials
  • Sends data out

The file

We split this skill into the rules it gives an agent

8 rules. About 0 are common, 0 are distinctive, and 8 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈0

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 8

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Read .buildpad/project.json at the project root.

    line 14
  2. Very specific:

    If a folder named supabase at the project root holds a file named config.toml, or holds a folder named migrations that holds a file whose …

    line 19
  3. Very specific:

    Always create jobs as inactive initially, test them, then activate.

    line 75
  4. Very specific:

    Every write is recorded in the activity log under the system account, with the user agent daas-automation-runtime/cron

    line 292
  5. Very specific:

    All cron API calls from the browser must go through Next.js proxy routes (Rule 4: No CORS).

    line 453
  6. Very specific:

    If a job takes longer than its schedule interval, the next tick is automatically skipped.

    line 523
  7. Very specific:

    Default: 100 rows per job (configurable via CRON_HISTORY_RETENTION).

    line 527
  8. Very specific:

    Default: 100 rows per job (CRON_HISTORY_RETENTION, set by the Engine's operator).

    line 635

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Create Cron Job

<!-- backend-gate:begin v1 -->

Establish the backend first. A Buildpad project's backend is either the Buildpad Engine or Supabase, and some parts of this file hold for one of them only. Before you act on anything in this file, find the backend with these steps, in order, and stop at the first step that answers:

  1. Read .buildpad/project.json at the project root. If it is JSON and its field backend is "engine" or "supabase", that is the backend. If .buildpad/backend-answer.json names the other backend, tell the developer the two files disagree, and follow .buildpad/project.json.
  2. Otherwise read .buildpad/backend-answer.json in the same way; if its backend is "engine" or "supabase", that is the backend.
  3. If a folder named supabase at the project root holds a file named config.toml, or holds a folder named migrations that holds a file whose name ends in .sql, the backend is Supabase.
  4. Otherwise ask the developer once: "Is this project's backend the Buildpad Engine or Supabase?" Write the answer to .buildpad/backend-answer.json as {"buildpadBackendAnswer": 1, "backend": "engine"} or {"buildpadBackendAnswer": 1, "backend": "supabase"}, and ask the developer to commit that file. Do not add it to .gitignore. Never write .buildpad/project.json.

To find the backend, do not open an environment file (.env, .env.*) or an MCP configuration file, and do not call a tool that returns project credentials.

Then follow only the parts for that backend. A part starts at a line that holds only a comment whose text is buildpad + -backend: engine or buildpad + -backend: supabase, and ends at the next line that holds only a comment whose text is /buildpad + -backend. A comment that merely mentions a backend is not a part. A file whose first line, after any front matter, holds only a comment whose text is buildpad + -backend-file: engine (or supabase) holds for that backend only, and so does every file in a folder named engine-only or supabase-only. Skip the other backend's parts and copy no code from them; everything else holds for both. On an Engine project, when this file says that something is not available, tell the developer so and stop: do not build a substitute.

<!-- backend-gate:end --><!-- buildpad-backend: engine -->

On an Engine project with Customer_Tenants each customer of the application is a tenant of its own, and the project's tenant is the Blueprint every customer is copied from. Establish the tenancy mode as the add-multitenancy skill says: from the field tenancy of .buildpad/project.json, else from .buildpad/tenancy-answer.json. In the mode customer-tenants that skill's guide engine-customer-tenants replaces what ES-3, ES-4, ES-5, ES-7 and ES-9 say about the project's tenant; in the mode scope they hold as written.

<!-- /buildpad-backend -->

Set up scheduled background tasks using the DaaS cron system. Cron jobs run recurring logic — archive stale records, send digests, sync external systems — without deploying new code.

Overview

<!-- buildpad-backend: supabase -->

Cron jobs are stored in the database (daas_cron_jobs) and executed at runtime using the same JavaScript sandbox as Runtime Extensions. They are managed via REST API or MCP.

<!-- /buildpad-backend --><!-- buildpad-backend: engine -->

Cron jobs are stored in the Engine (daas_cron_jobs) and executed by the Engine's runtime, the same runtime as Runtime Extensions. They are managed in the Studio's Cron module, through the Engine's REST API (<Engine_URL>/api/cron) with an administrator's session, or through the Engine's MCP tool cron, which is listed and served to an administrator only (see Cron REST Routes).

On an Engine project every data call carries the signed-in user's access token as Authorization: Bearer <access token>, from the application's own server route or from the SDK. The Engine accepts no cookie session on its data API. The token in the project's MCP configuration is accepted only on the MCP endpoint /api/mcp: never send it, or any other administrator token, to the data API.

On an Engine project an active cron job fires at its scheduled time. A job can also be run by hand at any time (POST /api/cron/:id/run).

<!-- /buildpad-backend -->

Key properties:

PropertyDescription
ScheduleStandard 5-field cron expression (min hour dom mon dow)
TimezoneIANA timezone string (default UTC)
CodeJS snippet (same sandbox as runtime extensions)
Statusactive / inactive
TimeoutMax execution time in ms (default 30,000)
HistoryEvery run is recorded in daas_cron_history

Setup Steps

<!-- buildpad-backend: supabase -->

1. Create a Cron Job (Inactive First)

Always create jobs as inactive initially, test them, then activate.

{
  "name": "mcp_daas_cron",
  "arguments": {
    "action": "create",
    "name": "Nightly Cleanup",
    "description": "Archive stale sessions every night at midnight",
    "schedule": "0 0 * * *",
    "timezone": "Asia/Singapore",
    "code": "const items = await services.items('sessions');\nconst cutoff = new Date(Date.now() - 24 * 3600_000).toISOString();\nconst stale = await items.readByQuery({ filter: { updated_at: { _lt: cutoff }, status: { _eq: 'pending' } }, limit: 500 });\nfor (const session of stale) { await items.deleteOne(session.id); }\nconsole.log(`Cleaned up ${stale.length} stale sessions`);",
    "status": "inactive",
    "timeout_ms": 60000
  }
}

2. Test with Manual Trigger

{
  "name": "mcp_daas_cron",
  "arguments": {
    "action": "run_now",
    "id": "<job-id>"
  }
}

3. Check Execution History

{
  "name": "mcp_daas_cron",
  "arguments": {
    "action": "history",
    "id": "<job-id>",
    "limit": 5
  }
}

4. Activate When Satisfied

{
  "name": "mcp_daas_cron",
  "arguments": {
    "action": "activate",
    "id": "<job-id>"
  }
}
<!-- /buildpad-backend --><!-- buildpad-backend: engine -->

1. Create a Cron Job (Inactive First)

Always create jobs as inactive initially, test them, then activate. Create one in the Studio's Cron module, or send the request below from a server-side script or route with an administrator's access token (the Engine also lets a user whose role holds create on daas_cron_jobs do it). An omitted status is stored as inactive.

curl -X POST "$ENGINE_URL/api/cron" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Nightly Cleanup",
    "description": "Archive stale sessions every night at midnight",
    "schedule": "0 0 * * *",
    "timezone": "Asia/Singapore",
    "code": "const items = services.items(\"sessions\");\nconst cutoff = new Date(Date.now() - 24 * 3600000).toISOString();\nconst stale = await items.readByQuery({ filter: { updated_at: { _lt: cutoff }, status: { _eq: \"pending\" } }, limit: 500 });\nfor (const session of stale) { await items.deleteOne(session.id); }\nconsole.log(`Cleaned up ${stale.length} stale sessions`);",
    "status": "inactive",
    "timeout_ms": 60000
  }'

$ACCESS_TOKEN is the access token of a signed-in administrator, never the token in the project's MCP configuration. The answer is 201 with the stored job under data. A schedule that is not a cron expression, an unknown timezone, or code that does not compile is refused with 400 and the server's sentence, and nothing is stored.

2. Test with Manual Trigger

curl -X POST "$ENGINE_URL/api/cron/<job-id>/run" -H "Authorization: Bearer $ACCESS_TOKEN"

The answer comes when the run has ended: { "data": { "historyId": "<uuid>", "message": "Cron job Nightly Cleanup triggered manually" } }. An empty historyId means the job was already running and nothing ran. A run whose code failed still answers 200; its outcome is the status of the history row. Running a job needs update on daas_cron_jobs.

3. Check Execution History

curl "$ENGINE_URL/api/cron/<job-id>/history?limit=5" -H "Authorization: Bearer $ACCESS_TOKEN"

Each row carries status (running, success, error or timeout), error, duration_ms, triggered_by (schedule, manual or extension) and logs, one entry per console call, each in the form [<time>] [INFO|WARN|ERROR] <message>.

4. Activate When Satisfied

curl -X PATCH "$ENGINE_URL/api/cron/<job-id>" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "status": "active" }'

On an Engine project an active cron job fires at its scheduled time. Activating the job starts its scheduled runs; to run it once at another time, run it by hand as in step 2 (or from the Studio's Cron module).

<!-- /buildpad-backend -->

Cron Expressions

Standard 5-field syntax: min hour dom mon dow

ExpressionMeaning
* * * * *Every minute
0 * * * *Every hour
0 9 * * 1-5Weekdays at 09:00
0 0 * * *Daily at midnight
0 0 1 * *Monthly (1st)
*/15 * * * *Every 15 minutes
0 */6 * * *Every 6 hours
30 2 * * 0Sundays at 02:30

Code Sandbox

Cron code runs in the same sandboxed environment as runtime extensions.

<!-- buildpad-backend: supabase -->

Available Context Variables

VariableAvailableDescription
context✅{ jobId, jobName, triggeredBy, runId, scheduledAt }
services✅Full services object (12 members — see table below)
console✅Captured output (appears in history logs[])
JSON✅Standard JSON object
Date✅Standard Date object
Math✅Standard Math object

NOT available in cron jobs (these are extension-only):

  • ❌ payload — Not applicable
  • ❌ meta — Not applicable
  • ❌ event — Not applicable
  • ❌ accountability — Not injected

Cron jobs access data exclusively through services.* calls.

Background Process User Context

Cron jobs run as the System Service user (00000000-0000-0000-0000-000000000000). This means:

  • user-created and user-updated special fields are auto-set to the system user UUID
  • Audit log entries (daas_activity) attribute mutations to this system user
  • Permission checks are bypassed (admin-level access)
  • The system user cannot log in interactively — it exists only in daas_users, not in auth.users

Important: readByQuery() returns Item[] directly — not { data: Item[] }. The { data } wrapper only exists in REST API responses. Do not destructure with const { data } = await items.readByQuery(...).

Available Services

ServiceAccess
services.items(collection)Full ItemsService (readByQuery, createOne, updateOne…)
services.collections()CollectionsService
services.fields()FieldsService
services.files()FilesService
services.versions()VersionService
services.relations()RelationsService
services.mail()MailService instance (send(), verify())
services.mail(options)Send email directly (shorthand, returns SendMailResult)
services.fetch(url, opts)Domain-restricted HTTP fetch (same safeFetch as extensions)
services.custom(name)Get a custom reusable service module (see Custom Services)
services.supabaseRaw Supabase client (service role, bypasses RLS)
services.envWhitelisted environment variables (NODE_ENV, NEXT_PUBLIC_SITE_URL)

mcp_daas_cron Actions

ActionParametersPurpose
list—List all cron jobs
readidRead job details
createname, code, schedule, description?, timezone?, status?, timeout_ms?Create a cron job
updateid, name?, code?, schedule?, status?, timeout_ms?, …Update a cron job
deleteidDelete a cron job
run_nowidManually trigger job immediately
activateidSet status to active
deactivateidSet status to inactive
historyid, limit?View run history with logs
cloneid, name?Clone a cron job

Blocked Globals

setTimeout, setInterval, require, import, eval, Function constructor.

<!-- /buildpad-backend --><!-- buildpad-backend: engine -->

Available Context Variables

VariableAvailableDescription
context✅{ jobId, jobName, triggeredBy, runId, scheduledAt }; runId is the id of this run's history row, scheduledAt an ISO-8601 string in UTC
services✅The Engine's services (see the table below)
console✅Captured output (appears in the history row's logs)
JSON✅Standard JSON object
Date✅Standard Date object
Math✅Standard Math object

NOT available in cron jobs (these are extension-only):

  • ❌ payload — Not applicable
  • ❌ meta — Not applicable
  • ❌ event — Not applicable
  • ❌ accountability — Not injected

Cron jobs access data exclusively through services.* calls. The code is the body of an async function in strict mode: await and return work at the top level, and assigning to an undeclared name is a ReferenceError.

Background Process User Context

On an Engine project a cron job runs as the Engine's system account (00000000-0000-0000-0000-000000000000), whether the schedule, a manual run or other stored code started it. This means:

  • The system account holds the Administrator policy, so its services.items calls pass every permission check; { elevated: true } changes nothing for it
  • user_created and user_updated fields, where a collection has them, are set to the system account's id
  • Every write is recorded in the activity log under the system account, with the user agent daas-automation-runtime/cron
  • The system account cannot sign in

Important: readByQuery() returns Item[] directly — not { data: Item[] }. The { data } wrapper only exists in REST API responses. Do not destructure with const { data } = await items.readByQuery(...).

Available Services

ServiceAccess
services.items(collection, opts?)Nine methods: readByQuery, readOne, readMany, createOne, createMany, updateOne, updateMany, deleteOne, deleteMany
services.collections()Read only: readAll, readOne
services.fields()Read only: readAll, readOne
services.relations()Read only: readAll, readOne
services.files()File metadata only: readByQuery, readOne
services.versions()readAll, readOne, promote
services.mail(options)Send email; returns { success, ... } and does not throw when mail cannot be sent
services.mail()An object with send(options) and verify()
services.fetch(url, init?)HTTP to the hosts the Engine's operator allows
services.custom(name, opts?)A custom service module; await it (see Custom Services)
services.crontrigger(idOrName), list(status?), get(idOrName)
services.envOnly the variables the Engine's operator names; empty by default

On an Engine project stored code (hooks, cron jobs, custom services) runs in the Engine's runtime, which provides only what the table Engine runtime lists; for each interface it lacks, the table says what replaces it.

Engine runtime

What a cron job's code has on the Engine, and what replaces what it lacks.

Interface taught on a Supabase projectOn the EngineReplacement
The Supabase service in stored code (services.supabase)Refused permanently: 501 RUNTIME_SUPABASE_UNAVAILABLEservices.items(collection), with { elevated: true } where a hook or custom service must act as an administrator
SQL functions, remote procedure calls and raw SQL from stored codeNo pathservices.items calls, or a custom service built on them
services.items(c) methodsNine: readByQuery, readOne, readMany, createOne, createMany, updateOne, updateMany, deleteOne, deleteMany; readByQuery returns the array itselfRead result, not result.data; loop over updateOne/deleteOne where a query-wide update or delete was used
Query keysfields, filter, sort, limit, page, offset, search; any other key is refused. Without limit a read returns 25 rows; limit is capped at 1000Drop the key; set limit
Filters across relationsOne relation deep ({ "comments.status": { _eq: "published" } } or { comments: { _has: true } }); a path through two relations, _some and _none are refusedRead the related collection first
Schema servicesRead only: readAll, readOneChange structure through the Engine's API (see create-collection)
services.filesMetadata only: readByQuery, readOneUpload and download through the application and /api/files
services.mailOptions to, subject, html, text, replyTo; returns { success, ... } and does not throw when mail cannot be sent; a wrong option throws 400 INVALID_PAYLOADCheck success
services.envEmpty unless the project's Environment Variables name keys (RUNTIME_ENV_WHITELIST_CRON, RUNTIME_ENV_WHITELIST_GLOBAL) or sets prefixed variables (CRON_PUBLIC_<KEY>, RUNTIME_PUBLIC_<KEY>); NODE_ENV and NEXT_PUBLIC_SITE_URL are not exposedTell the user to name the key in the project's Environment Variables (Engine list) and apply it; never assume one
services.fetchOnly hosts in the project's EXTENSION_ALLOWED_DOMAINS (and their subdomains); an empty list allows localhost only; responses over 8 MiB are refusedTell the user to add the host to EXTENSION_ALLOWED_DOMAINS in the project's Environment Variables and apply it
fetch, process, require, import, setTimeout, setInterval, eval, FunctionDo not exist: using one is a ReferenceErrorservices.fetch; no timers
Intl (for example Intl.NumberFormat)Not in the runtime's globalsFormat numbers and dates by hand
console.table, console.traceDo not exist; log, info, debug, warn, error onlyconsole.log
A run's memorymemory_limit_mb is stored and shown but not enforcedKeep batches small; timeout_ms is the only bound
Filesystem extensions, /api/ext/* endpointsNoneStored hooks, cron jobs, custom services

Cron REST Routes

On an Engine project cron jobs are managed through these routes, the Studio's Cron module, or the Engine's MCP tool cron. The tool is listed and served to an administrator only, so the agent can use it when the project's MCP configuration carries an administrator's token. Its action is list, read, create, update, delete, activate, deactivate, clone, run_now or history; the job's fields (name, schedule, timezone, code, status, timeout_ms) and id stand at the top level of arguments, and a change takes effect as the route's write of the same body does. A delete is refused while the project's MCP deletes are off. If the tool is not listed, the project's Engine is older than 0.1.0-dev.4: use these routes.

MethodPathNeeds on daas_cron_jobsPurpose
GET/api/cronreadList jobs (search, status, page, limit)
POST/api/croncreateCreate a job
GET/api/cron/:idreadRead one job
PATCH/api/cron/:idupdateUpdate the fields sent; status activates or deactivates
DELETE/api/cron/:iddeleteDelete a job and its history
POST/api/cron/:id/runupdateRun the job now
POST/api/cron/:id/clonecreate and readCopy a job (optional body { "name": "<new name>" }); the copy is inactive
GET/api/cron/:id/historyreadRuns of one job, newest first
GET/api/cron/historyreadRuns of every job, newest first

An administrator passes every check. A grant on daas_cron_history opens nothing.

Blocked Globals

setTimeout, setInterval, require, import, eval, Function constructor, fetch, process, and the file system.

<!-- /buildpad-backend -->

Common Patterns

Data Cleanup

// Archive items older than 30 days
const items = await services.items('temp_uploads');
const cutoff = new Date(Date.now() - 30 * 24 * 3600_000).toISOString();
const expired = await items.readByQuery({
  filter: { created_at: { _lt: cutoff } },
  limit: 1000,
});
for (const item of expired) {
  await items.deleteOne(item.id);
}
console.log(`Archived ${expired.length} expired uploads`);

Digest / Report Generation

// Daily summary of new items
const items = await services.items('orders');
const yesterday = new Date(Date.now() - 24 * 3600_000).toISOString();
const newOrders = await items.readByQuery({
  filter: { created_at: { _gte: yesterday } },
  fields: ['id', 'total', 'status'],
});
const total = newOrders.reduce((sum, o) => sum + (o.total || 0), 0);
console.log(`Daily report: ${newOrders.length} orders, total: $${total}`);

External Sync

<!-- buildpad-backend: supabase -->
// Sync contacts to external CRM every 6 hours
const items = await services.items('contacts');
const unsynced = await items.readByQuery({
  filter: { synced: { _eq: false } },
  limit: 100,
});
for (const contact of unsynced) {
  // Process sync logic with services.supabase if needed
  await items.updateOne(contact.id, { synced: true, synced_at: new Date().toISOString() });
}
console.log(`Synced ${unsynced.length} contacts`);
<!-- /buildpad-backend --><!-- buildpad-backend: engine -->
// Sync contacts to an external CRM every 6 hours
const items = services.items('contacts');
const unsynced = await items.readByQuery({
  filter: { synced: { _eq: false } },
  limit: 100,
});
for (const contact of unsynced) {
  // Send the contact with services.fetch to a host the Engine's operator allows
  await items.updateOne(contact.id, { synced: true, synced_at: new Date().toISOString() });
}
console.log(`Synced ${unsynced.length} contacts`);
<!-- /buildpad-backend -->

Status Escalation

// Auto-escalate unresolved tickets after 48 hours
const items = await services.items('tickets');
const cutoff = new Date(Date.now() - 48 * 3600_000).toISOString();
const overdue = await items.readByQuery({
  filter: {
    _and: [
      { status: { _eq: 'open' } },
      { created_at: { _lt: cutoff } },
      { priority: { _neq: 'critical' } }
    ]
  },
  limit: 200,
});
for (const ticket of overdue) {
  await items.updateOne(ticket.id, { priority: 'critical', escalated_at: new Date().toISOString() });
}
console.log(`Escalated ${overdue.length} overdue tickets`);
<!-- buildpad-backend: supabase -->

Next.js API Proxy Routes

Need an admin UI for cron jobs? Do not hand-build pages over these routes. The Cron Jobs module (add-cron, npx @buildpad/cli@latest add cron-routes) ships the jobs list, the job editor and the run history, and calls DaaS directly from the browser — it needs explicit DaaS CORS origins, and none of the proxy routes below. The routes below are for an app's own server-side or custom calls.

All cron API calls from the browser must go through Next.js proxy routes (Rule 4: No CORS).

Proxy Route Pattern

// app/api/cron/route.ts
import { NextRequest } from 'next/server';
import { proxyToDaaS } from '@/lib/proxy';

export async function GET(request: NextRequest) {
  return proxyToDaaS(request, '/api/cron');
}

export async function POST(request: NextRequest) {
  return proxyToDaaS(request, '/api/cron');
}
// app/api/cron/[id]/route.ts
import { NextRequest } from 'next/server';
import { proxyToDaaS } from '@/lib/proxy';

export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  return proxyToDaaS(request, `/api/cron/${id}`);
}

export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  return proxyToDaaS(request, `/api/cron/${id}`);
}

export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  return proxyToDaaS(request, `/api/cron/${id}`);
}
// app/api/cron/[id]/run/route.ts
import { NextRequest } from 'next/server';
import { proxyToDaaS } from '@/lib/proxy';

export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  return proxyToDaaS(request, `/api/cron/${id}/run`);
}
// app/api/cron/[id]/history/route.ts
import { NextRequest } from 'next/server';
import { proxyToDaaS } from '@/lib/proxy';

export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  return proxyToDaaS(request, `/api/cron/${id}/history`);
}

Environment Variables

VariableDefaultDescription
CRON_ENABLEDtrueSet false to disable all jobs
CRON_HISTORY_RETENTION100History rows kept per job

Overlap Protection

If a job takes longer than its schedule interval, the next tick is automatically skipped. The DB running flag prevents concurrent runs of the same job.

History Retention

Default: 100 rows per job (configurable via CRON_HISTORY_RETENTION). Old rows are pruned automatically after each run.

<!-- /buildpad-backend --><!-- buildpad-backend: engine -->

Next.js API Proxy Routes

Need an admin UI for cron jobs? Do not hand-build pages over these routes. The Cron Jobs module (add-cron, npx @buildpad/cli@latest add cron-routes) ships the jobs list, the job editor and the run history, and calls the Engine directly from the browser with the user's access token — it needs no cross-origin setting and none of the routes below. The routes below are for an app's own server-side or custom calls.

On an Engine project every data call carries the signed-in user's access token as Authorization: Bearer <access token>, from the application's own server route or from the SDK. The Engine accepts no cookie session on its data API. The token in the project's MCP configuration is accepted only on the MCP endpoint /api/mcp: never send it, or any other administrator token, to the data API.

Proxy Route Pattern

Each route forwards the signed-in user's Authorization header to the Engine and passes the Engine's status and { errors: [...] } body through unchanged.

// lib/engine-forward.ts
import { NextRequest, NextResponse } from 'next/server';
import { getAuthHeaders, getDaasUrl } from '@/lib/api/auth-headers';

export async function forwardToEngine(request: NextRequest, path: string) {
  const headers = await getAuthHeaders();
  const body = ['GET', 'HEAD'].includes(request.method) ? undefined : await request.text();
  const response = await fetch(`${getDaasUrl()}${path}${request.nextUrl.search}`, {
    method: request.method,
    headers: { ...headers, 'Content-Type': 'application/json' },
    body,
    cache: 'no-store',
  });
  const text = await response.text();
  return new NextResponse(text || null, {
    status: response.status,
    headers: { 'Content-Type': response.headers.get('content-type') ?? 'application/json' },
  });
}
// app/api/cron/route.ts
import { NextRequest } from 'next/server';
import { forwardToEngine } from '@/lib/engine-forward';

export async function GET(request: NextRequest) {
  return forwardToEngine(request, '/api/cron');
}

export async function POST(request: NextRequest) {
  return forwardToEngine(request, '/api/cron');
}
// app/api/cron/[id]/route.ts
import { NextRequest } from 'next/server';
import { forwardToEngine } from '@/lib/engine-forward';

export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  return forwardToEngine(request, `/api/cron/${id}`);
}

export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  return forwardToEngine(request, `/api/cron/${id}`);
}

export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  return forwardToEngine(request, `/api/cron/${id}`);
}
// app/api/cron/[id]/run/route.ts
import { NextRequest } from 'next/server';
import { forwardToEngine } from '@/lib/engine-forward';

export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  return forwardToEngine(request, `/api/cron/${id}/run`);
}
// app/api/cron/[id]/history/route.ts
import { NextRequest } from 'next/server';
import { forwardToEngine } from '@/lib/engine-forward';

export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  return forwardToEngine(request, `/api/cron/${id}/history`);
}

Environment Variables

These are settings of the Engine, made by its operator, not variables of the application:

VariableDefaultDescription
CRON_ENABLEDtruefalse stops scheduled runs; manual runs still work
CRON_HISTORY_RETENTION100History rows kept per job
CRON_STALE_LOCK_GRACE_MS30000Added to a job's timeout_ms before a lock left by a stopped process is taken over

Overlap Protection

A job never runs twice at once. A run takes the job's lock first; a run that finds the lock held is skipped, and a manual run then answers an empty historyId. A lock left by a process that stopped mid-run is taken over once timeout_ms plus CRON_STALE_LOCK_GRACE_MS has passed.

History Retention

Default: 100 rows per job (CRON_HISTORY_RETENTION, set by the Engine's operator). Older rows of that job are removed after each run.

<!-- /buildpad-backend -->

References

  • Cron expressions & sandbox
<!-- buildpad-backend: supabase -->
  • Cron MCP tool
<!-- /buildpad-backend -->
  • Cron API routes

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.