Triggers and Public Route Gating
How finite jobs and persistent agents are exposed once deployed, and what gates a public route before a request reaches one.
Triggers are definition metadata that tell generated servers which entrypoints may be exposed in
production. Put triggers inside defineAgent({...}) or return them from createAgent(...).
Declaring triggers
import { defineAgent } from '@fabric-harness/sdk';
export default defineAgent({
name: 'webhook-triage',
triggers: {
webhook: true,
},
async run({ input }) {
return input;
},
});Default behavior
- Finite job:
webhook: trueenablesPOST /jobs/:namein production. - Persistent agent:
webhook: trueenablesPOST /agents/:name/:idin production. - Without it, the definition remains available to local/dev and CLI flows, but production public
invocation returns
403 Forbidden.
Public route gating
For Node-derived deployments, gate public routes with:
--auth-token-env <var>(CLI / env) requiring a bearer token.--max-body-bytesto cap body size.--rate-limit-window-ms+--rate-limit-maxfor per-IP rate limits.
For Cloudflare, apply route-level rules around /jobs/<name> and /agents/<name>/<instanceId> in
addition to the Worker bearer token.
Schedule triggers
The Node server runs finite schedule triggers with cron-parser:
export default defineAgent({
name: 'daily-report',
triggers: { schedule: '0 9 * * 1-5' },
async run({ input }) { /* ... */ },
});Persistent-agent configs reject triggers.schedule: a cron expression alone cannot identify the
instance, named session, or message to deliver. Put the schedule on a bounded finite job and
dispatch explicitly:
import { defineAgent, dispatch } from '@fabric-harness/sdk';
export default defineAgent({
name: 'daily-assistant-wakeup',
triggers: { schedule: '0 9 * * 1-5' },
async run({ input }) {
return dispatch({
agent: 'assistant',
id: 'daily-ops',
message: 'Review the morning operations queue.',
dispatchId: `daily-assistant:${input.scheduledAt}`,
tenantId: 'system:daily-operations',
actor: { agentId: { id: 'daily-scheduler', type: 'service' } },
});
},
});This keeps the persistent identity and input explicit while the finite wrapper retains the normal bounded scheduling, lease, idempotency, and run-inspection behavior.
On Cloudflare, a persistent agent instance can also own its wake-up times directly. The generated
Worker exposes per-instance schedules (GET/POST /agents/:name/:id/schedules,
DELETE .../schedules/:scheduleId) backed by Durable Object alarms, and each fire is admitted as a
durable submission whose delivered message is a signal with type: "schedule" — visible to the
agent through useDelivery() and to custom loop runtimes as PromptRunInput.delivery. See
Per-instance schedules.
Finite-job scheduling stays on triggers.schedule; the Cloudflare build maps those expressions to
Worker Cron Triggers (triggers.crons in wrangler.jsonc) as described in
Cloudflare Cron Triggers below.
Configure timezone and payload resolution when embedding the server:
await startDevServer({
scheduler: {
timezone: 'America/Phoenix',
payload: (jobName, expression, scheduledAt) => ({ scheduledAt: scheduledAt.toISOString() }),
catchUp: 'run-once',
},
});Set FABRIC_HARNESS_SCHEDULER_ENABLED=0 to disable it. The scheduler calculates each next cron
boundary before execution, skips overlapping runs of the same job, emits normal session events,
and stops its timers during graceful shutdown.
catchUp: "skip" is the default. With a lease store that supports durable cursors,
catchUp: "run-once" executes only the most recent occurrence missed during downtime. A first
start establishes the cursor and does not replay historical cron time. Public dispatch() accepts
dispatchId, tenantId, and actor, so scheduled persistent-agent delivery can be both
idempotent and explicitly owned.
Multiple Node replicas
Use the Postgres lease store so every cron occurrence and active job has one owner across replicas:
import { Pool } from 'pg';
import { postgresSchedulerLeaseStore, startDevServer } from '@fabric-harness/node';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
await startDevServer({
scheduler: {
leaseStore: postgresSchedulerLeaseStore(pool),
leaseMs: 60_000,
},
});The active lease renews while the job runs. An occurrence claim remains long enough to prevent a
late or restarted replica from replaying the same cron tick. memorySchedulerLeaseStore() is the
single-process default and is useful for tests, not multi-replica coordination.
Generated Databricks Apps bind scheduled work to the App principal's stable system tenant.
Lakebase-backed Apps also wire the Postgres lease/cursor store and use run-once catch-up
automatically. Without Lakebase, scheduling is process-local, uses skip, and should remain
single-replica.
Each claimed Node occurrence is admitted as a durable finite run. It receives a run receipt, can be
inspected through /runs/:runId, and deduplicates across replicas using the schedule expression and
scheduled timestamp. If an occurrence is more than Node's maximum timer delay away, the scheduler
re-arms the same occurrence at each timer boundary; it does not execute the job early.
Scheduled Databricks App runs belong to the App principal's tenant. A signed-in user's approval discovery route therefore cannot see a request raised inside that run. For human-gated schedules, persist a proposal for a later user-owned interaction or use a trusted decision bridge that retains the exact session/request receipt and resolves it through the stored approval boundary. The Buzz bridge supplies the transport correlation primitives; the application still owns identity, membership, expiry, authorization, and compare-and-swap checks.
Cloudflare Cron Triggers
fh build --target cloudflare discovers every schedule expression, writes it to
wrangler.jsonc under triggers.crons, and emits a Worker scheduled() handler. Each occurrence
gets a deterministic run ID and executes through the same finite-job function as POST /jobs/:name.
Inspect it with GET /runs/:runId and read offset events from GET /runs/:runId/events?offset=0.
Cloudflare serializes duplicate deliveries for that run ID through its Durable Object and returns
the existing run instead of executing twice.
Cron expressions use the target runtime's timezone rules: Node accepts an explicit IANA timezone;
Cloudflare Cron Triggers run in UTC. During DST changes, cron-parser advances missing wall-clock
times to the next valid instant and executes a repeated wall-clock occurrence once.