TechFabricTechFabricHarness
Reference

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: true enables POST /jobs/:name in production.
  • Persistent agent: webhook: true enables POST /agents/:name/:id in 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-bytes to cap body size.
  • --rate-limit-window-ms + --rate-limit-max for 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.