Triggers and Public Route Gating
How finite jobs and persistent agents are exposed once deployed.
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() {
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.
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.