Anatomy of a `.swirls` file
One .swirls file from the Swirls team's own GTM stack, read top to bottom. An OIDC-federated identity, a schedule, a graph with a scoped-delegated HTTP call, a durable Postgres-backed stream, and a trigger. One file. Five primitives. No long-lived API tokens. This is what a deployable agent looks like.
There is a file in the Swirls team's GTM repo that searches GitHub for repos matching our ICP, normalizes the results into lead records, and writes them to a durable, queryable Postgres-backed stream. Every morning at 07:30 UTC the runtime executes it. Nobody on the team logs in to push a button. Nobody wrote a service to wrap it. Nobody pasted a long-lived GitHub token into a .env file.
The file is the workflow. Not a service that runs it. Not a wrapper around a model call. The file.
This post is a reading of that file. Swirls is the runtime that executes it. We will go top to bottom, one block at a time, and at each block name the production property the runtime provides.
#Read this file
Here is the outline, block headers only:
connection gtm_github { ... }
schedule gtm_github_schedule { ... }
graph gtm_github_discovery {
root { ... }
node fetch { ... }
node pack { ... }
flow { ... }
}
stream gtm_leads_github { ... }
trigger gtm_github_on_schedule { ... }
Five blocks. Each one is a primitive the runtime understands. Top to bottom, then: here is what each block does.
#The connection block
connection gtm_github {
label: "GitHub via Swirls OIDC federation"
provider: github
}
This is identity as a primitive, and specifically the federated form. The connection block names a brokered credential the runtime can scope to a single HTTP call. The provider is all the file declares; a human authorizes the grant once, and the runtime fetches a short-lived token at call time. Nodes reference the connection by name (connection: gtm_github), so there is no ID to copy and the file is reusable across any number of nodes.
Notice what is not in this file. There is no secret block declaring GITHUB_TOKEN. There is no process.env.GITHUB_TOKEN waiting in production. There is no leaked-token rotation playbook. Swirls handles the OAuth dance with GitHub on the file's behalf; the file just names the connection.
This matters more in agentic systems than it does in traditional services. When an LLM is choosing which tool to call, a long-lived API token in the process is a liability that outlives the request that exposed it. An OIDC-federated credential is minted per call, scoped to the call, and short-lived by construction. The .swirls file gets the security property without paying the usual cost of OAuth client setup, because the runtime owns that part.
The auth type here is cloud (the spec's managed-connection form). The other valid types are oauth (raw OAuth where the file holds the client credentials), api_key, basic, and bearer. Use cloud whenever the provider is one Swirls can federate to. Use the others only when you must.
#The schedule
schedule gtm_github_schedule {
label: "GTM GitHub repo search"
cron: "30 7 * * *"
timezone: "UTC"
enabled: true
}
Time is a first-class trigger source. The schedule is declared at the file level rather than wired up in a separate cron.yaml or a parent service. When the workflow ships, the schedule ships with it.
#The graph
The graph is where the work happens. Three nodes and a flow block:
graph gtm_github_discovery {
label: "Search GitHub for workflow-related repos"
root {
type: code
label: "Query params"
outputSchema: @json {
{
"type": "object",
"required": ["discoveredAt", "source", "q"],
"properties": {
"discoveredAt": { "type": "string" },
"source": { "type": "string" },
"q": { "type": "string" }
},
"additionalProperties": false
}
}
code: @ts {
return {
discoveredAt: new Date().toISOString(),
source: 'github',
q: 'workflow+engine+OR+agent+OR+orchestration+language:TypeScript',
}
}
}
...
}
Every graph has exactly one root node. This one is a code node that computes the query parameters for the GitHub search. The outputSchema is the contract: the runtime validates the return value against the JSON Schema before the next node runs. If the shape is wrong, the run fails at the boundary. The error does not surface three nodes later when something downstream tries to read a missing field. Schema validation at every node boundary is what makes a graph composable: each node is a small typed function the next node can rely on.
The HTTP call is next, and this is the load-bearing line of the file:
node fetch {
type: http
label: "GitHub search API"
method: GET
connection: gtm_github
url: @ts {
const q = context.nodes.root.output.q
return (
'https://api.github.com/search/repositories?q=' +
encodeURIComponent(q) +
'&per_page=15&sort=updated'
)
}
}
connection: gtm_github. One line. This is scoped delegation: the runtime fetches a fresh short-lived GitHub token at call time and hands it to this one HTTP call.
The node does not see other secrets. The graph does not see the credential. The credential is bound to the call. The process does not hold it. If the workflow grows a second HTTP node tomorrow that talks to a different service, that node's connection: reference is the only thing that decides which credential reaches the wire.
The third node is another code node that turns the GitHub API response into a uniform leads array (domain, company name, snippet, source metadata) ready to write downstream. Then the flow block connects the nodes:
flow {
root -> fetch
fetch -> pack
}
One edge per line. No conditionals at the edge level (conditional routing uses a switch node). The graph is a DAG, and the DAG is the workflow's control flow.
#The stream
stream gtm_leads_github {
label: "GTM leads from GitHub"
graph: gtm_github_discovery
enabled: true
schema: @json { ... }
condition: @ts {
const p = context.output.pack
return Array.isArray(p?.leads) && p.leads.length > 0
}
prepare: @ts {
return context.output.pack
}
}
This is the state-of-record. A top-level stream block subscribes to a graph (graph: gtm_github_discovery), defines the shape of what gets written (schema), decides whether a given run produces a row (condition), and shapes the row from the graph's output (prepare). Subscribing here is literal: every time the runtime finishes a successful run of the named graph, it evaluates the stream's condition, and if the condition returns true it writes one row using whatever prepare returns.
Every successful run that satisfies the condition leaves exactly one row in gtm_leads_github. The stream is materialized in Postgres. A SELECT over gtm_leads_github is a real query the operator can run, and it is the same data the runtime uses when a downstream graph reads from this stream as input.
The workflow's history is not a log file. It is a queryable table.
#The trigger
trigger gtm_github_on_schedule {
schedule:gtm_github_schedule -> gtm_github_discovery
enabled: true
}
One line of binding syntax. The schedule fires; the graph runs; the stream collects. Triggers are declarative. They are not buried in application middleware or wired up in a separate event-routing service. The binding is part of the artifact, which means there is no separate "wire it up in production" step.
#What's not in this file
The biggest absence is the agent primitive. The file you just read is a workflow: deterministic execution path, schema-validated at every boundary, the runtime in charge of what runs when. Swirls also lets you declare top-level agent blocks:
agent concierge {
label: "Concierge"
provider: openrouter
model: "openai/gpt-4o-mini"
maxSteps: 8
tools: [ tool_fetch, tool_transform ]
system: @ts { return "..." }
profile analyst {
description: "Narrow tooling: transform only."
tools: [ tool_transform ]
}
}
An agent is an LLM-orchestrated execution. Tools are graphs (you literally pass graph names in tools: [ ... ]), so the workflow primitive we just walked through is the unit that agents call. Optional profile blocks narrow the tool set for specific personas. A graph invokes an agent through a type: agent node.
The properties Swirls layers on top of an agent block are why it is its own primitive. Each agent invocation runs in a sandbox the runtime controls. Network egress is isolated and routed through Swirls. Secrets are scoped per role. The model does not see the rest of the process. This is the property the workflow file in this post cannot give you on its own, because workflows are deterministic by design.
A .swirls file can mix the two freely. The same file can declare workflows for the deterministic parts, agent blocks for the LLM-orchestrated parts, and have graphs that invoke agents via type: agent nodes. One artifact format. One runtime.
The other blocks this file does not use:
review:blocks on any node: the runtime pauses execution until a human approves, with a typed form schema for the reviewer (seereview-workflow.swirlsin the canonical examples)graphnodes that call a subgraph as a tool, under scoped delegationparallelnodes for Parallel.ai web research (search,extract, orfindall— not general workflow concurrency)postgresblocks at the top level, paired withpostgresnodes that run parameterizedSELECTorINSERTagainst external databasesformandwebhookblocks instead of (or alongside) a schedulesecretblocks and theapi_key/basic/bearer/ rawoauthauth types, for providers Swirls cannot federate to yet
Same artifact format. Different slots filled.
#You just read a workflow
What you read was not a route handler. It was not a Lambda function with a package.json next to it. It was not a config file for an app that calls a model. It was the workflow.
Swirls turns this file into the cloud-hosted workflow that runs. The same lines you reviewed in a PR are what the platform compiles and deploys. The credential is minted per call. The state is in Postgres. The schedule lives in the file. The whole thing is one artifact. When the workflow needs an LLM-orchestrated step, the file grows an agent block and a type: agent node, and the runtime sandboxes that part separately. If you want to see more .swirls files, swirls.ai is where to start.
A note on the example: the version of this file currently running in our GTM stack uses a bearer-token auth block against a stored GitHub PAT. The OIDC federation form shown above is what the file becomes when GitHub federation lands in the next few weeks. Everything else in the file is what we run today.