How a normal agent works
A typical agent is a model choosing the next tool every turn. Staff as code lets you keep that session, or pin the clock to a composition instead.
Orkestia7 min read
Orkestia is the backbone that connects software, AI, and the real world. Most people meet that backbone through a familiar shape: a model, a prompt, a list of tools, a loop. That is a normal agent. It is also how a Staff session works here. The difference is what you do when the path is already known.
This post is that split. A session is non-deterministic on purpose. A composition is deterministic on purpose. Staff as code is how you write the choice down, review a plan, and apply it to your org.
A typical agent is a loop
Give a model a task, a system prompt, and tools. Each turn it reads the messages so far and either calls a tool or stops. The runtime executes the call, appends the result, and asks again. The path is chosen at run time. Two runs with the same prompt can pick different tools, in a different order, and stop for different reasons.
That loop is the right default when the work is judgment. Read a pull request and decide what to say. Classify a messy inbox item. Write code against a spec you have not seen before. The model has to look, then choose.
It is the wrong default when the work is a queue. Oldest unrated session. Oldest unlabeled ticket. Oldest unpaid invoice. You already know the steps. Paying a model to rediscover them every five minutes wastes budget, and it can invent a step you did not want.
A typical agent (left) is a model in a loop with tools. A composition (right) is layers of catalog types mapped in advance. Staff as code (bottom) is the clock that picks one or the other in git.
Two kinds of actor on the same backbone
A session is the typical agent, hosted. Staff launches
agents.session-launch onto a runner in your cloud. The actor has a
model, skills, MCP servers, a budget, and a history. RBAC is the same
guard a person hits in the console. There is no side door. That session
is non-deterministic: the model still chooses the next tool.
A composition is a virtual workflow: layers of existing catalog types, with every input mapped from the composition input, a prior step, or a static literal. The platform validates it against the live catalog and compiles it to a DAG. The engine runs that DAG like any other type. You watch it, retry it, and audit it on the same run id. The path does not change between runs.
Staff can start either. A person in the console can start either. An MCP client can start either. The object is always a workflow run.
Compositions themselves: Creating and exposing virtual workflows. Staff governance: Staff governance.
When the model still belongs
Keep a session when the next step is not a function of typed outputs you already have. A pull-request reviewer is the clean case. The actor wakes on an event, reads the diff with skills you granted, and writes a review. Staff as code for that looks like this:
import { call, events, skill, slot, staff, usd } from "@orkestia/staff"
const getExactPr = skill("github.pulls.get_exact_pr")
const listFiles = skill("github.pulls.list_pr_files")
const reviewPr = skill("github.pulls.review_pr")
export default staff("acme", ({ org }) => {
org
.businessUnit("acme")
.vertical("engineering")
.unit("pull-request-review", { title: "Pull Request Review" })
.actor("pr-code-reviewer", { kind: "trigger" })
.limits({ maxSteps: 24, budget: usd(16) })
.listensTo(events.pullRequest.reviewReady())
.canUse(getExactPr, listFiles, reviewPr)
.promptedBy([
"You review one open PR per wake.",
`First action: ${call(getExactPr, { repository: slot("REPOSITORY"), pull_number: slot("PR_NUMBER") })}`,
])
})
The prompt is standing instructions. The skills are the only tools the session may call. The event is the wake. The model still chooses what to say in the review. That is a normal agent, named, seated, and audited.
When the path is already known
Take a quality loop every customer with Staff eventually wants: rate finished agent sessions, FIFO, and open a tuning ticket only when the score is bad and the defect is something you can actually fix (prompt, tools, budget).
If that loop is a session, two things go wrong.
- The rater is itself a session, so it becomes another row in the queue it is supposed to drain.
- The model re-plans list, trace, score, rate, maybe ticket, every wake. Empty traces, skipped rows, and "do I file a ticket" become prose again.
The composition is the known path. List one unrated completed session.
If the page is empty, stop. If the trace has no tool calls, stamp a
noop score and move on. Otherwise ask TypeSafe Jev for a score and a
defect, write agents.session-rate, and open ticket.open with
dispatch=false only for the cases you pinned.
Author it as structure, not as a prompt. The TypeScript is the same
defineComposition surface the console DAG builder compiles to:
import { defineComposition, t, truthy } from "@orkestia/workflows"
export default defineComposition(
{
name: "session-quality-scan",
description: "FIFO-rate one unrated terminal agent session.",
inputs: {
connection_uuid: t.uuid().describe("TypeSafe connection for the decide step."),
completed_after: t.string().describe("ISO-8601 floor. In-flight sessions have no completed_at."),
},
},
(c) => {
const found = c.step("data.agents.list-sessions", {
unrated: true,
completed_after: c.input.completed_after,
sort: "completed_at",
order: "asc",
limit: 1,
})
const sessionId = c.step("control.value.get", {
data: found.sessions,
path: "0.session_uuid",
})
const hasSession = truthy(sessionId.found)
c.step("agents.session-rate", {
session_uuid: sessionId.value,
quality_score: 4,
notes: "noop-session",
}).when(hasSession)
// Real traces go through typesafe.systemone.evaluate, then session-rate,
// then ticket.open only for prompt / tools / budget at score 1 or 2.
},
)
Save and activate. The live type is virtual.<uuid>@N. Invoke it like
any other workflow. Pin the question set for Jev as static so a caller
cannot widen it. That pattern is
Typed decisions with TypeSafe.
Staff as code is the wiring
Hiring the actor in the Staff console still works. Staff as code is the same blueprint in git: units, actors, clocks, and whether a clock starts a session or a composition.
.loop(seconds, task) is the cadence. Without a target, the scheduler
falls through to agents.session-launch and you get a normal agent every
tick. .dispatchesVia sets schedule.target to a composition type, so
the clock starts that DAG and never opens a session of its own.
import { staff } from "@orkestia/staff"
const QUALITY_SCAN = "virtual.<uuid>@1"
export default staff("acme-quality", ({ org }) => {
org
.businessUnit("acme")
.vertical("engineering")
.unit("quality", {
title: "Quality",
charter: "Rate finished agent sessions. Do not code or merge.",
})
.actor("session-rater", { kind: "loop" })
.loop(
300,
"FIFO-rate one unrated terminal session; open a tuning ticket only for prompt, tools, or budget at score 1-2.",
)
.dispatchesVia(QUALITY_SCAN, {
connection_uuid: process.env.TYPESAFE_CONNECTION_UUID,
completed_after: process.env.QUALITY_SINCE ?? "2026-09-01T00:00:00Z",
})
})
The actor still exists. It still has a config, a unit, a seat. It has no
system prompt and no tools, because it does not think. Jev, if you use
it, lives inside the composition as typesafe.systemone.evaluate, not as
the actor's model.
Apply is a plan you can read. Create and update only the refs you meant. The live org stays the source of truth after apply.
orkestia staff compile
orkestia staff plan --dir app/staff/quality
orkestia staff apply --dir app/staff/quality --yes
Confirm on the actor that schedule.target.workflow_type is
virtual.<uuid>@N. The next fire should be that type, not
agents.session-launch. Manual staff.invoke-actor can still launch a
session. The loop uses the schedule target.
How to choose
| You have | Use |
|---|---|
| A spec, a diff, or an inbox item that still needs judgment | Session. Prompt, skills, budget, event or invoke. |
| A queue with a known FIFO path | Composition. Layers, mappings, virtual.<uuid>@N. |
| A clock that should drain that queue | Staff as code .loop plus .dispatchesVia. |
| A typed allow / block / score in the middle | typesafe.systemone.evaluate as a step, questions pinned static. |
| A human in front of a mutation | Approval gate on that type. The composition stops before the effect. |
The typical agent does not go away. It is one of two execution modes, and Staff as code is where you record which mode each actor is.
A useful first afternoon:
- Open staff.orkestia.dev and hire one trigger actor the way Staff: actors that run the work describes.
- Author a three-step composition for a queue you already understand.
Validate, save, invoke
virtual.<uuid>@1once by hand. - Declare the same unit in TypeScript with
.dispatchesViapointing at that type.staff plan, then apply only the actor update. - Watch the next fire. The run type in history should be the composition.
What to read next
- staff
- agents
- compositions
