Rensei docs
Runtime

Workflow Gates

Signal/timer/webhook gates, suspension, and onTimeout.

Gates are nodes that suspend a running workflow instance until an external condition is satisfied - a human approval, a timer expiry, or an inbound webhook signal. When the DAG executor reaches a gate node it returns status: 'waiting', which causes the orchestrator to persist the instance as suspended and exit. Later, when the triggering event arrives (or the timer fires), the executor resumes from the tier immediately after the gate, replaying no already-completed steps.

Gate types

The GateSystem supports three gate types, all stored in Redis:

Prop

Type

Redis key patterns

All gate state lives in Redis so the platform can survive process restarts without losing suspension state:

KeyTypePurpose
wf:gate:{gateId}String (JSON)Full GateRegistration - config, status, timestamps
wf:gate:signal:{eventType}SetGate IDs waiting for a specific event type
wf:gate:instance:{instanceId}SetAll gate IDs for a workflow instance
wf:gate:timersSorted setGate IDs scored by expiresAt ms

On process restart, GateSystem.rehydrateGates() scans wf:gate:* and re-indexes any still-waiting registrations into the signal and timer indexes, restoring the system to a consistent state.

The human_query gate node

gate.human_query is the only palette-registered gate node. It creates a row on the Approvals page, optionally emails reviewers, and suspends the workflow until a reviewer approves, rejects, or the configured timeout fires.

# Minimal human_query gate
- id: require-approval
  type: gate
  config:
    nodeId: gate.human_query
    timeoutValue: 48
    timeoutUnit: hours
    onTimeout: approve    # auto-approve if nobody acts in 48h

The bespoke config panel handles the timeout/onTimeout fields. Internally, resolveStepGateConfig projects these onto the executor's V2GateData shape so suspension, timer registration, and approval row creation all happen automatically.

onTimeout actions

Prop

Type

Sub-minute gate precision is explicitly not supported. The gate-timer cron runs once per minute on Vercel Cron. For sub-minute SLAs, the architecture notes suggest migrating to an Inngest durable event queue - this is not yet implemented.

Gate output and branch routing

When a gate is resumed, the DAG executor receives a synthetic StepResult for the gate node. The output and branch label are derived from the CloudEvent:

Resume pathBranch labelGate output
Human approved'approved'{ result: 'approved', ...approvalData }
Human rejected'rejected'{ result: 'rejected', ...approvalData }
Timeout → escalate / skip'timeout''timeout' (bare string)
Timeout → approve'approved'{ result: 'approved', autoApproved: true, ... }
Timeout → deny'rejected'{ result: 'rejected', autoRejected: true, ... }
Signal / webhook (unlabeled edges)'default'Raw CloudEvent data

If the gate has outgoing edges labeled 'approved' and 'rejected', the executor routes accordingly. An unlabeled gate (no labeled edges) continues all downstream nodes regardless of the approval outcome.

Gate handles must be serialized as step.branches in the workflow definition - not as step.next with edge labels - for branch routing to work. The canvas does this automatically via canvasToDefinitionV2. If you author YAML by hand, use the branches field for conditional gate routing.

Signal gates and agent session completion

The platform synthesises a com.linear.AgentSessionEvent.completed CloudEvent when a session reaches a terminal status. This makes gate.signal nodes waiting on that event type resume automatically when an agent finishes - no external webhook needed.

- id: wait-for-agent
  type: gate
  config:
    gateType: signal
    signalEventType: com.linear.AgentSessionEvent.completed
    signalFilter:
      agentSession:
        id: "{{ nodes.dispatch-agent.output.agentSessionId }}"
  branches:
    default: next-step

The synthetic CloudEvent is produced by dispatchGateSignal in the session lifecycle hooks. Its id is deterministic on (sessionId, status) so retried lifecycle hook calls produce the same event - and since GateSystem.matchSignal skips non-waiting gates, idempotency is preserved end-to-end.

Resuming a gate via the API

Approval decisions are submitted through the Approvals page UI, which calls the platform's approval-gate API. The API emits a workflow.approval.decision CloudEvent internally.

Inbound webhooks from Linear, GitHub, or Vercel are normalized to CloudEvents by the webhook ingest gateway. The ingest route calls findMatchingGates then resumeGate + resumeInstanceById for every matching waiting gate.

# Pause, resume, cancel, or retry an instance via operator API
curl -X POST https://app.rensei.ai/api/workflows/{workflowId}/instances/{instanceId}/resume \
  -H "Authorization: Bearer rsk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "signal": "approved" }'

Re-entry guard

runInstanceFromGate enforces a re-entry guard: if the instance's suspendedGateNodeId does not match the gateNodeId passed to the resume, the call is a no-op. This prevents a stale approval-page row click from re-firing a resume on an instance that has already advanced past that gate and is now waiting at a downstream gate.

Resume execution flow

Fetch and validate instance - the instance must be in 'suspended' status. Re-entry guard checks suspendedGateNodeId.

Parse frozen definition snapshot - every suspended instance carries the workflow YAML it was running, making it immune to edits made after suspension.

Rebuild execution graph - DAGExecutor.buildGraph(definition) reconstructs the same graph that was running at suspension time.

Hydrate step results - loadCompletedStepResults(instanceId) reads step_executions rows and rebuilds the completedSteps map so already-run nodes are not re-executed.

Transition to running - InstanceLifecycle.resume(instanceId) moves the instance from suspendedrunning.

Execute with resumeFrom - DAGExecutor.execute is called with opts.resumeFrom. Tiers up to and including the gate's tier are skipped; the gate's synthetic result is injected; condition branching is applied for approved/rejected/timeout edges; execution continues from the next tier.

Finalize - mirrors normal run: lifecycle.complete, lifecycle.suspend (hit another gate), or lifecycle.fail.

Timer cron

The GET /api/cron/gate-timer route runs on a Vercel Cron at minute granularity. It:

  1. Calls GateSystem.processTimeouts() which scans wf:gate:timers for scores <= Date.now() and marks them timed_out.
  2. Calls resumeTimedOutGates(processed) which drives each timed-out gate through the appropriate resume path based on its onTimeout action.

The cron is idempotent: a gate that is already in a non-waiting state is skipped during timeout processing.

On this page