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:
# 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:
primitive sync push --dir ./config
primitive workflows publish welcome-emailStep Types
| Type | Description |
|---|---|
llm.chat | Call an LLM (OpenRouter) with messages |
gemini.generate | Call Google Gemini |
prompt.execute | Run a managed prompt |
integration.call | Call an external API via configured integration |
transform | Transform data with a template expression |
delay | Pause execution for a specified duration |
event.wait | Wait for an external event/webhook |
noop | No operation (useful for conditional branching) |
database.query | Run a registered database query operation |
database.mutate | Run a registered database mutation operation |
database.count | Run a registered database count operation |
database.aggregate | Run a registered database aggregation |
database.pipeline | Run a registered database pipeline |
group.addMember | Add a user to a group |
group.removeMember | Remove a user from a group |
group.checkMembership | Check if a user belongs to a group |
group.listMembers | List members of a group |
group.listUserMemberships | List groups a user belongs to |
collect | Auto-paginate a data source and merge all pages |
workflow.call | Run another workflow inline (synchronously) |
workflow.start | Start child workflow instances in parallel |
workflow.await | Wait for child workflow instances to complete |
database.applyToQuery | Query-and-mutate in one server-side pass |
database.executeBatch | Apply many individual writes with per-item CEL access |
analytics.query | Run an analytics query (see Analytics Query Step below) |
email.send | Send an email (template-based or inline; see Email Steps below) |
blob | Upload 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 valuesConditional Execution
Skip steps based on conditions:
[[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
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:
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:
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
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:
[[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:
primitive email-templates set order-confirmation \
--subject "Your order #{{orderId}}" \
--html-file ./order.htmlInline Mode
For one-off or dynamically constructed emails, specify subject and body directly in the step:
[[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:
[[steps]]
name = "top-users-weekly"
type = "analytics.query"
queryType = "top-users"
windowDays = 7
limit = 25All 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.
# 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:
[workflow]
key = "handle-payment"
name = "Handle Payment"
status = "active"
accessRule = "hasRole('owner')" # Only webhook triggers can start this — clients are blockedThe 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:
{ "inputMapping": "data.object" }Each event (accepted, rejected, duplicate) is logged and viewable via the API. Manage webhooks via the CLI:
# 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
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:
# 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 accessPrompt Configurations
A prompt can have multiple configurations for different LLM providers and settings:
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.7Testing Prompts
Define test cases to validate prompt behavior:
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-summarizerVerification types include contains, pattern (regex), json_subset, and llm_eval (uses an LLM to judge output quality).
Using Prompts in Your App
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
[[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
# 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 }}"# Add the secret
primitive integrations secrets add weather-api WEATHER_API_KEY
# Push config
primitive sync push --dir ./configCalling from Your App
const response = await client.integrations.call({
integrationKey: "weather-api",
method: "GET",
path: "/forecast/san-francisco",
query: { units: "metric" },
});CLI Workflow
# 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
- Working with Databases — Server-side data that workflows can act on
- Scheduled and Real-Time Automation — Cron triggers and live database subscriptions
- Blob Buckets — Storage that workflows write to via the
blobstep - Primitive CLI — Full CLI command reference
- Overview — See how workflows fit into the platform