Skip to main content
Your workflow doesn’t run in isolation. It needs to notify your dashboard when a step completes, wait for a human to approve a document, let a monitoring service check progress, or accept configuration changes mid-run. Output gives you primitives for both directions: sending data out and receiving data in.

Quick Reference

I need to…UseDirection
Notify a service or call a webhooksendHttpRequestOut
Send a request, pause until callbacksendPostRequestAndAwaitWebhookOut, then In
Let external systems push data inSignalIn
Let a dashboard read current stateQueryIn
Accept a change and confirm it workedUpdateIn

Sending Data Out

Post a Notification

The simplest integration: call an external API when something happens. Use sendHttpRequest to POST to a webhook — notify any HTTP-based service, kick off a Zapier automation, etc.
// src/workflows/content_pipeline/workflow.ts
import { workflow, sendHttpRequest } from '@outputai/core';
import { research, draft, review } from './steps.js';

export default workflow({
  name: 'content_pipeline',
  description: 'Research, draft, and review content with progress notifications',
  fn: async (input) => {
    const researchResults = await research(input.topic);

    // Notify your dashboard or monitoring service
    await sendHttpRequest({
      url: `${process.env.INTERNAL_API_URL}/events`,
      method: 'POST',
      payload: {
        event: 'step_complete',
        workflow: 'content_pipeline',
        step: 'research',
        data: { topic: input.topic }
      }
    });

    const content = await draft({ topic: input.topic, research: researchResults });

    await sendHttpRequest({
      url: `${process.env.INTERNAL_API_URL}/events`,
      method: 'POST',
      payload: {
        event: 'step_complete',
        workflow: 'content_pipeline',
        step: 'draft',
        data: { title: content.title }
      }
    });

    const reviewed = await review(content);
    return reviewed;
  }
});
Each sendHttpRequest fires and continues — the workflow doesn’t wait for a response beyond the HTTP acknowledgment.

Parameters

ParameterTypeDescription
urlstringThe request URL (required)
method'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'HTTP method (default: GET)
payloadobjectRequest body for POST/PUT (optional)
headersRecord<string, string>HTTP headers (optional)
The response includes status, statusText, headers, body, and ok.

Signed Webhooks

When sending data out, it’s common practice for the receiving end to require a shared secret that is signed. Here’s an example for how to sign a webhook payload using HMAC-SHA256:
Signing must happen in a step, not directly in the workflow. Cryptographic operations like crypto.createHmac are non-deterministic and must run inside a step.
// src/workflows/signed_webhook/steps.ts
import { step, z } from '@outputai/core';
import crypto from 'crypto';

export const sendSignedWebhook = step({
  name: 'sendSignedWebhook',
  description: 'Send a webhook with HMAC signature',
  inputSchema: z.object({
    url: z.string(),
    payload: z.record(z.unknown())
  }),
  outputSchema: z.object({ status: z.number() }),
  fn: async ({ url, payload }) => {
    const secret = process.env.WEBHOOK_SECRET;
    const body = JSON.stringify(payload);

    const signature = crypto
      .createHmac('sha256', secret)
      .update(body)
      .digest('hex');

    const response = await fetch(url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Signature': signature
      },
      body
    });

    return { status: response.status };
  }
});
// src/workflows/signed_webhook/workflow.ts
import { workflow } from '@outputai/core';
import { sendSignedWebhook } from './steps.js';

export default workflow({
  name: 'order_complete',
  fn: async (input) => {
    // ... process order ...

    await sendSignedWebhook({
      url: 'https://partner-api.example.com/webhooks',
      payload: {
        event: 'order_complete',
        orderId: input.orderId,
        total: input.total
      }
    });

    return { success: true };
  }
});
The receiver verifies by computing the same HMAC and comparing signatures.

Receiving Data In

Sometimes your workflow needs input from the outside world while it’s running. A human approves a document. A dashboard checks progress. An operator changes a setting. Output exposes three primitives for this, built on Temporal’s message passing.
PrimitiveBlocks caller?Can change state?Returns value?Use when…
SignalNoYesNoFire-and-forget input (stop, add data)
QueryYesNoYesRead current state (progress, status)
UpdateYesYesYesChange state and confirm (set threshold)

Query: Read State Without Changing It

A query lets external systems read workflow state. The workflow keeps running — queries are read-only and don’t affect execution.
Why two imports? Output wraps Temporal for workflow orchestration. High-level helpers like workflow and sendHttpRequest come from @outputai/core. Low-level primitives like defineQuery, defineSignal, and setHandler come directly from @temporalio/workflow — Output doesn’t wrap these since Temporal’s API is already clean.
Job: Your dashboard needs to show which step the workflow is on.
// src/workflows/content_pipeline/workflow.ts
import { workflow } from '@outputai/core';
import { defineQuery, setHandler } from '@temporalio/workflow';
import { research, draft, review } from './steps.js';

// defineQuery<ReturnType>('name') - the generic specifies what the query returns
const getProgress = defineQuery<{ step: string; completed: number; total: number }>('getProgress');

export default workflow({
  name: 'content_pipeline',
  description: 'Content pipeline with progress query',
  fn: async (input) => {
    let currentStep = 'research';
    let completedSteps = 0;
    const totalSteps = 3;

    setHandler(getProgress, () => ({
      step: currentStep,
      completed: completedSteps,
      total: totalSteps
    }));

    const researchResults = await research(input.topic);
    completedSteps = 1;
    currentStep = 'draft';

    const content = await draft({ topic: input.topic, research: researchResults });
    completedSteps = 2;
    currentStep = 'review';

    const reviewed = await review(content);
    completedSteps = 3;
    currentStep = 'done';

    return reviewed;
  }
});
Query the workflow via HTTP:
curl -X POST http://localhost:3001/workflow/content_pipeline-abc123/query/getProgress \
  -H "Content-Type: application/json"
Response:
{ "step": "draft", "completed": 1, "total": 3 }
Your dashboard can poll this endpoint to show a progress bar — no webhooks needed.
About the payload wrapper: When sending data to signals, queries, or updates, wrap your data in { "payload": {...} }. The API extracts payload and passes it to your handler. For queries with no arguments, send an empty object: { "payload": {} } or omit the body entirely.

Query with Arguments

Queries can accept arguments from the caller. Use this to filter results, control verbosity, or request specific data. Job: An agent makes multiple tool calls per step. The dashboard wants to show either a summary or detailed tool call history.
// Define query with input arguments: defineQuery<ReturnType, [ArgumentType]>
const getProgress = defineQuery<
  ProgressResult,
  [{ verbose?: boolean; toolCallId?: string }]
>('getProgress');

// In your workflow
interface ToolCall {
  id: string;
  tool: string;
  input: unknown;
  output: unknown;
  timestamp: number;
}

const toolCalls: ToolCall[] = [];
let currentStep = 'planning';

setHandler(getProgress, (args) => {
  // Return specific tool call if requested
  if (args.toolCallId) {
    const call = toolCalls.find(t => t.id === args.toolCallId);
    return { toolCall: call ?? null };
  }

  // Return full history or summary
  if (args.verbose) {
    return { currentStep, toolCalls, totalCalls: toolCalls.length };
  }
  return { currentStep, totalCalls: toolCalls.length };
});

// During execution, record each tool call
const searchResult = await webSearch({ query: 'AI safety' });
toolCalls.push({
  id: `tc-${toolCalls.length + 1}`,
  tool: 'web_search',
  input: { query: 'AI safety' },
  output: searchResult,
  timestamp: Date.now()
});
Query with arguments:
# Summary view
curl -X POST http://localhost:3001/workflow/agent-abc123/query/getProgress \
  -H "Content-Type: application/json" \
  -d '{ "payload": {} }'
# { "currentStep": "research", "totalCalls": 5 }

# Detailed view
curl -X POST http://localhost:3001/workflow/agent-abc123/query/getProgress \
  -H "Content-Type: application/json" \
  -d '{ "payload": { "verbose": true } }'
# { "currentStep": "research", "totalCalls": 5, "toolCalls": [...] }

# Specific tool call
curl -X POST http://localhost:3001/workflow/agent-abc123/query/getProgress \
  -H "Content-Type: application/json" \
  -d '{ "payload": { "toolCallId": "tc-3" } }'
# { "toolCall": { "id": "tc-3", "tool": "web_search", ... } }

Signal: Push Data In

A signal pushes data into a running workflow. The caller doesn’t wait for processing — the API returns immediately. Use signals for fire-and-forget input like “stop processing” or “here’s new data.” Job: An operator wants to stop a long-running research workflow early.
// src/workflows/iterative_research/workflow.ts
import { workflow } from '@outputai/core';
import { defineSignal, setHandler, condition } from '@temporalio/workflow';
import { executeResearch, validateQuality } from './steps.js';

const stopSignal = defineSignal('stop');

export default workflow({
  name: 'iterative_research',
  description: 'Research with early stop capability',
  fn: async (input) => {
    let shouldStop = false;
    let results = [];

    setHandler(stopSignal, () => {
      shouldStop = true;
    });

    for (let iteration = 1; iteration <= 3; iteration++) {
      const iterationResults = await executeResearch({
        topic: input.topic,
        iteration
      });
      results.push(...iterationResults);

      const quality = await validateQuality(results);

      // Check if operator sent stop signal
      if (shouldStop) {
        break;
      }

      // Also stop if quality is good enough
      if (quality.score >= 0.8) {
        break;
      }
    }

    return { results, iterations: results.length };
  }
});
Send the stop signal:
curl -X POST http://localhost:3001/workflow/iterative_research-abc123/signal/stop
The workflow checks shouldStop between iterations and exits early. The operator gets 200 OK immediately — they don’t wait for the workflow to actually stop.

Update: Change State and Get Confirmation

An update changes workflow state and returns a result. The caller blocks until the workflow processes the update. Use updates when you need confirmation that a change was applied. Job: An admin wants to change the quality threshold mid-workflow and see the previous value.
// src/workflows/iterative_research/workflow.ts
import { workflow } from '@outputai/core';
import { defineUpdate, setHandler } from '@temporalio/workflow';
import { executeResearch, validateQuality } from './steps.js';

const setThreshold = defineUpdate<number, [{ threshold: number }]>('setThreshold');

export default workflow({
  name: 'iterative_research',
  description: 'Research with adjustable quality threshold',
  fn: async (input) => {
    let qualityThreshold = 0.8;

    setHandler(setThreshold, (params) => {
      const previous = qualityThreshold;
      qualityThreshold = params.threshold;
      return previous;  // Caller receives this
    });

    // ... research iterations using qualityThreshold ...
  }
});
Send the update:
curl -X POST http://localhost:3001/workflow/iterative_research-abc123/update/setThreshold \
  -H "Content-Type: application/json" \
  -d '{ "payload": { "threshold": 0.9 } }'
Response:
0.8
The admin sees the previous threshold (0.8) and knows their change was applied.

Waiting for External Input

Two patterns for pausing a workflow until the outside world responds.

Pattern 1: Workflow Initiates the Request

Use sendPostRequestAndAwaitWebhook when your workflow knows who to ask and wants to pause until they respond. Job: Send a document for human approval, pause until they decide.
// src/workflows/document_approval/workflow.ts
import { workflow, sendPostRequestAndAwaitWebhook } from '@outputai/core';
import { generateDocument } from './steps.js';

export default workflow({
  name: 'document_approval',
  description: 'Generate document and wait for human approval',
  fn: async (input) => {
    const document = await generateDocument(input);

    // Workflow pauses here until approval service calls back
    const approval = await sendPostRequestAndAwaitWebhook({
      url: 'https://approval-service.example.com/request',
      payload: {
        documentId: document.id,
        title: document.title
      }
    }) as { approved: boolean; comments?: string };

    return {
      documentId: document.id,
      approved: approval.approved,
      comments: approval.comments
    };
  }
});
Output automatically includes the workflowId in the request. The approval service stores it and calls back when the human decides:
curl -X POST http://localhost:3001/workflow/document_approval-abc123/feedback \
  -H "Content-Type: application/json" \
  -d '{ "payload": { "approved": true, "comments": "Looks good" } }'
The workflow resumes with the approval data.

Pattern 2: External System Initiates

Use a signal with condition() when external systems contact your workflow unprompted. The condition() function pauses the workflow until a predicate becomes true — the workflow stays durable and doesn’t consume resources while waiting. Job: Wait for Stripe to confirm a payment succeeded.
// src/workflows/order_processing/workflow.ts
import { workflow } from '@outputai/core';
import { defineSignal, setHandler, condition } from '@temporalio/workflow';
import { createOrder, fulfillOrder } from './steps.js';

const paymentComplete = defineSignal<[{ paymentId: string; amount: number }]>('paymentComplete');

export default workflow({
  name: 'order_processing',
  description: 'Process order after payment confirmation',
  fn: async (input) => {
    const order = await createOrder(input);

    let payment: { paymentId: string; amount: number } | null = null;

    setHandler(paymentComplete, (data) => {
      payment = data;
    });

    // Workflow pauses here, no CPU consumed
    await condition(() => payment !== null);

    // Payment received, continue processing
    const result = await fulfillOrder({
      orderId: order.id,
      paymentId: payment!.paymentId
    });

    return result;
  }
});
Configure Stripe to send webhooks to your Output API. When Stripe confirms payment:
curl -X POST http://localhost:3001/workflow/order_processing-abc123/signal/paymentComplete \
  -H "Content-Type: application/json" \
  -d '{ "payload": { "paymentId": "pi_123", "amount": 9900 } }'
The workflow wakes up and continues.

Which Pattern to Use?

ScenarioPattern
Workflow knows who to ask (approval service, specific user)sendPostRequestAndAwaitWebhook
External system contacts workflow unprompted (webhook, event)Signal + condition()
Need to poll for updates while waitingAdd a query alongside the signal

Full Example: Research Agent with Human Override

A research agent that runs autonomously but accepts human intervention. Combines all the patterns: signals to stop or add questions, queries to check progress and tool calls, and updates to change settings.
// src/workflows/research_agent/workflow.ts
import { workflow } from '@outputai/core';
import {
  defineSignal,
  defineQuery,
  defineUpdate,
  setHandler
} from '@temporalio/workflow';
import { planQuestions, executeQuestion, synthesize } from './steps.js';

// Types
interface ToolCall {
  id: string;
  question: string;
  result: unknown;
  timestamp: number;
}

interface ProgressArgs {
  verbose?: boolean;
  toolCallId?: string;
}

// Define message handlers
const stopSignal = defineSignal('stop');
const addQuestionSignal = defineSignal<[{ question: string }]>('addQuestion');
const getProgress = defineQuery<unknown, [ProgressArgs]>('getProgress');
const setMaxIterations = defineUpdate<number, [{ max: number }]>('setMaxIterations');

export default workflow({
  name: 'research_agent',
  description: 'Autonomous research with human override capabilities',
  fn: async (input) => {
    // State
    let shouldStop = false;
    let maxIterations = 3;
    let currentIteration = 0;
    let questionsCompleted = 0;
    let totalQuestions = 0;
    let qualityScore: number | null = null;
    let additionalQuestions: string[] = [];
    const toolCalls: ToolCall[] = [];

    // Signals
    setHandler(stopSignal, () => {
      shouldStop = true;
    });

    setHandler(addQuestionSignal, (params) => {
      additionalQuestions.push(params.question);
    });

    // Query with arguments - return different data based on request
    setHandler(getProgress, (args = {}) => {
      // Return specific tool call
      if (args.toolCallId) {
        return { toolCall: toolCalls.find(t => t.id === args.toolCallId) ?? null };
      }

      // Return verbose or summary
      const summary = {
        iteration: currentIteration,
        questionsCompleted,
        totalQuestions,
        qualityScore
      };

      if (args.verbose) {
        return { ...summary, toolCalls };
      }
      return summary;
    });

    // Update
    setHandler(setMaxIterations, (params) => {
      const previous = maxIterations;
      maxIterations = params.max;
      return previous;
    });

    // Main research loop
    for (let i = 1; i <= maxIterations && !shouldStop; i++) {
      currentIteration = i;

      const questions = await planQuestions({
        topic: input.topic,
        previousResults: toolCalls.map(t => t.result),
        additionalQuestions
      });
      additionalQuestions = [];

      totalQuestions = questions.length;
      questionsCompleted = 0;

      for (const question of questions) {
        if (shouldStop) break;

        const result = await executeQuestion({ question });

        // Track each tool call for querying
        toolCalls.push({
          id: `tc-${toolCalls.length + 1}`,
          question,
          result,
          timestamp: Date.now()
        });
        questionsCompleted++;
      }

      const synthesis = await synthesize(toolCalls.map(t => t.result));
      qualityScore = synthesis.score;

      if (qualityScore >= 0.85) {
        break;
      }
    }

    return {
      results: toolCalls,
      iterations: currentIteration,
      finalScore: qualityScore
    };
  }
});

Interacting with the Agent

Start the research:
curl -X POST http://localhost:3001/workflow/start \
  -H "Content-Type: application/json" \
  -d '{ "workflowName": "research_agent", "input": { "topic": "AI safety" } }'
Check progress (summary):
curl -X POST http://localhost:3001/workflow/research_agent-abc123/query/getProgress \
  -H "Content-Type: application/json" \
  -d '{ "payload": {} }'
# { "iteration": 2, "questionsCompleted": 3, "totalQuestions": 5, "qualityScore": 0.72 }
Check progress (with full tool call history):
curl -X POST http://localhost:3001/workflow/research_agent-abc123/query/getProgress \
  -H "Content-Type: application/json" \
  -d '{ "payload": { "verbose": true } }'
# { "iteration": 2, ..., "toolCalls": [{ "id": "tc-1", "question": "...", "result": {...} }, ...] }
Inspect a specific tool call:
curl -X POST http://localhost:3001/workflow/research_agent-abc123/query/getProgress \
  -H "Content-Type: application/json" \
  -d '{ "payload": { "toolCallId": "tc-3" } }'
# { "toolCall": { "id": "tc-3", "question": "What are current AI safety challenges?", ... } }
Add a question the agent should investigate:
curl -X POST http://localhost:3001/workflow/research_agent-abc123/signal/addQuestion \
  -H "Content-Type: application/json" \
  -d '{ "payload": { "question": "What are the key alignment proposals?" } }'
Extend the research (and see old limit):
curl -X POST http://localhost:3001/workflow/research_agent-abc123/update/setMaxIterations \
  -H "Content-Type: application/json" \
  -d '{ "payload": { "max": 5 } }'
# Returns: 3
Stop early when quality is good enough:
curl -X POST http://localhost:3001/workflow/research_agent-abc123/signal/stop

Reference

Outbound Functions

Both are imported from @outputai/core and used inside workflow fn. sendHttpRequest(options) — Fire-and-forget HTTP request.
OptionTypeRequired
urlstringYes
method'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'No (default: GET)
payloadobjectNo
headersRecord<string, string>No
sendPostRequestAndAwaitWebhook(options) — POST then pause until callback.
OptionTypeRequired
urlstringYes
payloadobjectNo
headersRecord<string, string>No
Callback endpoint: POST /workflow/:id/feedback with { "payload": {...} }.

Inbound Primitives

All are imported from @temporalio/workflow and used inside workflow fn. Signal — Async, no return value.
const mySignal = defineSignal<[PayloadType]>('signalName');
setHandler(mySignal, (payload) => { /* mutate state */ });
HTTP: POST /workflow/:id/signal/signalName with { "payload": {...} } Query — Sync, read-only, returns value.
const myQuery = defineQuery<ReturnType>('queryName');
setHandler(myQuery, () => { return currentState; });
HTTP: POST /workflow/:id/query/queryName Update — Sync, can mutate, returns value.
const myUpdate = defineUpdate<ReturnType, [PayloadType]>('updateName');
setHandler(myUpdate, (payload) => { /* mutate */ return result; });
HTTP: POST /workflow/:id/update/updateName with { "payload": {...} } Condition — Pause until predicate is true.
await condition(() => someState !== null);

Error Handling

If the workflow doesn’t exist, the API returns a 404 error. If the workflow exists but doesn’t have a handler for the given signal/query/update name, Temporal throws an error. Make sure your handler names match between the workflow code and the API calls. For advanced patterns (validators, async handlers, timeouts), see Temporal’s message passing docs.