Skip to content

Workflows and Prompts

Primitive provides server-side workflow automation and managed LLM prompts. Workflows let you chain together LLM calls, data transformations, external API requests, and conditional logic — all defined as version-controlled TOML config files.

Workflows

A workflow is a multi-step automation pipeline that runs on the server. You define the steps, and Primitive executes them in sequence.

Creating a Workflow

Define a workflow in a TOML config file:

toml
# config/workflows/welcome-email.toml
[workflow]
name = "welcome-email"
displayName = "Welcome Email Workflow"

[[steps]]
name = "generate-message"
type = "llm.chat"
messages = [
  { role = "system", content = "You write friendly, concise welcome emails." },
  { role = "user", content = "Write a welcome email for {{ input.userName }}." },
]
temperature = 0.7
max_tokens = 500

[[steps]]
name = "send-email"
type = "integration.call"
integrationKey = "email-service"
method = "POST"
path = "/send"
body = """
{
  "to": "{{ input.userEmail }}",
  "subject": "Welcome!",
  "body": "{{ outputs.generate-message.content }}"
}
"""

Push it to the server:

bash
primitive sync push --dir ./config
primitive workflows publish welcome-email

Step Types

TypeDescription
llm.chatCall an LLM (OpenRouter) with messages
gemini.generateCall Google Gemini
prompt.executeRun a managed prompt
integration.callCall an external API via configured integration
transformTransform data with a template expression
delayPause execution for a specified duration
event.waitWait for an external event/webhook
noopNo operation (useful for conditional branching)
database.queryRun a registered database query operation
database.mutateRun a registered database mutation operation
database.countRun a registered database count operation
database.aggregateRun a registered database aggregation
database.pipelineRun a registered database pipeline
group.addMemberAdd a user to a group
group.removeMemberRemove a user from a group
group.checkMembershipCheck if a user belongs to a group
group.listMembersList members of a group
group.listUserMembershipsList groups a user belongs to
collectAuto-paginate a data source and merge all pages
workflow.callRun another workflow inline (synchronously)
workflow.startStart child workflow instances in parallel
workflow.awaitWait for child workflow instances to complete
database.applyToQueryQuery-and-mutate in one server-side pass
database.executeBatchApply many individual writes with per-item CEL access
analytics.queryRun an analytics query (see Analytics Query Step below)
email.sendSend an email (template-based or inline; see Email Steps below)
blobUpload or manipulate blobs in a bucket (see Blob Buckets)

Template Syntax

Steps can reference workflow inputs and outputs from previous steps:

{{ input.fieldName }}           # Workflow input
{{ outputs.step-name.field }}   # Output from a previous step
{{ meta.workflowRunId }}        # Workflow metadata
{{ input.name | default:"Anonymous" }}  # Fallback values

Conditional Execution

Skip steps based on conditions:

toml
[[steps]]
name = "premium-feature"
type = "llm.chat"
runIf = "{{ input.isPremium }}"
messages = [
  { role = "user", content = "Generate premium content for {{ input.topic }}." },
]

Starting a Workflow from Your App

typescript
import { jsBaoClientService } from "primitive-app";

const client = await jsBaoClientService.getClientAsync();

// Start a workflow
const { workflowRunId } = await client.workflows.start("welcome-email", {
  input: {
    userName: "Alice",
    userEmail: "alice@example.com",
  },
});

// Check status
const status = await client.workflows.getStatus(workflowRunId);
console.log(status.status); // "running", "completed", "failed"
console.log(status.outputs); // Final outputs when completed

// List recent runs
const { runs } = await client.workflows.listRuns("welcome-email");

Scheduling Workflows with Cron

Workflows don't have to be triggered by a user or a webhook — you can run them on a schedule. Create a cron trigger that fires a workflow on any standard cron expression, with IANA timezone support and an overlap policy:

bash
primitive cron-triggers create \
  --key "nightly-digest" \
  --workflow "send-digest" \
  --schedule "0 9 * * *" \
  --timezone "America/Los_Angeles"

See Scheduled and Real-Time Automation for the full cron walkthrough.

Debugging Workflow Runs

Every step's output is persisted and reachable via the app-level workflow run steps endpoint:

typescript
const steps = await client.workflows.runSteps(workflowRunId);
steps.forEach(step => {
  console.log(step.name, step.status, step.output);
});

You'll see the same data in the admin console under the run's detail view.

Real-Time Status via WebSocket

typescript
client.on("workflowStatus", (event) => {
  console.log(`Workflow ${event.workflowRunId}: ${event.status}`);
  if (event.status === "completed") {
    console.log("Result:", event.outputs);
  }
});

Email Steps

The email.send step sends an email from inside a workflow. It has two modes.

Template Mode

Use a registered email template — either a built-in type (magic-link, otp, etc.) or a custom one you registered:

toml
[[steps]]
name = "order-confirmation"
type = "email.send"
templateType = "order-confirmation"
to = "{{ input.customerEmail }}"
variables = { orderId = "{{ input.orderId }}", total = "{{ input.total }}" }

Register custom template types with any kebab-case name. Manage them with the CLI:

bash
primitive email-templates set order-confirmation \
  --subject "Your order #{{orderId}}" \
  --html-file ./order.html

Inline Mode

For one-off or dynamically constructed emails, specify subject and body directly in the step:

toml
[[steps]]
name = "report-ready"
type = "email.send"
to = "{{ input.email }}"
subject = "Your report is ready"
htmlBody = "<p>Download: <a href=\"{{ outputs.save.signedUrl }}\">link</a></p>"
textBody = "Download: {{ outputs.save.signedUrl }}"

Inline mode bypasses templates entirely — useful when the subject or body depends on upstream step output and creating a template for it wouldn't add value.

Analytics Query Step

Workflows can query analytics data server-side:

toml
[[steps]]
name = "top-users-weekly"
type = "analytics.query"
queryType = "top-users"
windowDays = 7
limit = 25

All 16 analytics query types are available (overview, top-users, user-detail, events, events-grouped, cohort-retention, workflows, prompts, integrations, etc.). Two things worth knowing:

  • Default deny, admin/owner bypass. The runner looks up the triggering user's app role and rejects non-admin callers before making the upstream call. Keep analytics workflows locked down with accessRule = "hasRole('admin')".
  • Per-run cap of 50 queries. If a single run issues more than 50 analytics queries, the excess are skipped.

Cache TTL overrides are available via cacheTtlSeconds — pass 0 or null to bypass the cache for a fresh read.

For the full picture — what events are emitted, the client-side logEvent API, and the matching CLI/REST surface — see Analytics.

Inbound Webhooks

Inbound webhooks let external services (Stripe, GitHub, Slack, etc.) trigger workflows automatically. Each webhook has a public URL, signature verification, and automatic workflow triggering.

bash
# Create a webhook that triggers a workflow when Stripe sends events
POST /app/{appId}/api/webhooks
{
  "webhookKey": "stripe-payments",
  "displayName": "Stripe Payments",
  "workflowKey": "handle-payment",
  "verificationScheme": "stripe",
  "signingSecret": "whsec_your_stripe_secret"
}

The receive endpoint is:

POST /app/{appId}/webhook/{webhookKey}

When an event is received, the webhook verifies the signature and starts the configured workflow with the event payload as rootInput. Supported verification schemes are stripe, github, slack, custom, and none.

Securing webhook workflows: Webhook-triggered workflows should use accessRule to prevent clients from bypassing signature verification by calling client.workflows.start() directly with a crafted payload:

toml
[workflow]
key = "handle-payment"
name = "Handle Payment"
status = "active"
accessRule = "hasRole('owner')"  # Only webhook triggers can start this — clients are blocked

The accessRule is a CEL expression evaluated on client.workflows.start() calls but not on webhook triggers (which have their own signature verification). Setting it to hasRole('owner') effectively restricts direct starts to app owners while allowing webhooks to trigger normally. See the Workflows Agent Guide for the full accessRule reference.

Use inputMapping to extract a nested path from the payload before passing it to the workflow:

json
{ "inputMapping": "data.object" }

Each event (accepted, rejected, duplicate) is logged and viewable via the API. Manage webhooks via the CLI:

bash
# Create/update webhook definitions
primitive webhooks push --dir ./config

# List webhooks
primitive webhooks list

# View recent events
primitive webhooks events <webhook-id>

Managed Prompts

Managed prompts are versioned, testable LLM prompt templates stored on the server. They help you iterate on prompts without redeploying your app.

Creating a Prompt

bash
primitive prompts create my-summarizer \
  --display-name "Document Summarizer" \
  --body "Summarize the following text in {{ input.style }} style:\n\n{{ input.text }}"

Or define in TOML:

toml
# config/prompts/summarizer.toml
[prompt]
name = "my-summarizer"
displayName = "Document Summarizer"
body = """
Summarize the following text in {{ input.style }} style:

{{ input.text }}
"""

Template Variables

Prompts use the same template syntax as workflows:

{{ input.variable }}              # Input parameter
{{ input.name | default:"text" }} # With fallback
{{ input.items[0].name }}         # Nested access

Prompt Configurations

A prompt can have multiple configurations for different LLM providers and settings:

bash
primitive prompts configs create my-summarizer \
  --name "fast" \
  --provider openrouter \
  --model gpt-4o-mini \
  --temperature 0.3

primitive prompts configs create my-summarizer \
  --name "quality" \
  --provider gemini \
  --model gemini-2.5-pro \
  --temperature 0.7

Testing Prompts

Define test cases to validate prompt behavior:

bash
primitive prompts test-cases create my-summarizer \
  --name "basic-test" \
  --input '{"text": "Long article text...", "style": "bullet points"}' \
  --verification-type contains \
  --expected-output "•"

# Run tests
primitive prompts test my-summarizer

Verification types include contains, pattern (regex), json_subset, and llm_eval (uses an LLM to judge output quality).

Using Prompts in Your App

typescript
const result = await client.prompts.execute("my-summarizer", {
  input: { text: documentText, style: "concise" },
  configName: "fast", // Optional: specify which configuration
});

console.log(result.output);

Using Prompts in Workflows

toml
[[steps]]
name = "summarize"
type = "prompt.execute"
promptName = "my-summarizer"
configName = "quality"
input = { text = "{{ input.documentText }}", style = "professional" }

External API Integrations

Integrations let your workflows (and client code) call external APIs securely. Primitive stores credentials server-side.

Defining an Integration

toml
# config/integrations/weather-api.toml
[integration]
name = "weather-api"
displayName = "Weather API"
baseUrl = "https://api.weather.com/v1"

[[integration.allowedPaths]]
path = "/forecast/*"
methods = ["GET"]

[integration.headers]
X-API-Key = "{{ secrets.WEATHER_API_KEY }}"
bash
# Add the secret
primitive integrations secrets add weather-api WEATHER_API_KEY

# Push config
primitive sync push --dir ./config

Calling from Your App

typescript
const response = await client.integrations.call({
  integrationKey: "weather-api",
  method: "GET",
  path: "/forecast/san-francisco",
  query: { units: "metric" },
});

CLI Workflow

bash
# Initialize config
primitive sync init --dir ./config

# Pull current server config
primitive sync pull --dir ./config

# Edit TOML files locally...

# See what changed
primitive sync diff --dir ./config

# Push changes
primitive sync push --dir ./config

# Publish a workflow (make it executable)
primitive workflows publish my-workflow

# Monitor runs
primitive workflows runs list
primitive workflows runs get <runId>

Next Steps