Wrapping your app
session, agent, chain and workflow — when to use which — plus dynamic identity binding.
Wrapping an entrypoint does two things: it groups every LLM call inside into one trace, and it
attaches identity (user_id, paid_plan, session_id) so your rules can target that traffic.
You get the same tagging four ways — pick by shape.
The four wrappers
| Wrapper | Shape | Recorded as |
|---|---|---|
workflow | Decorator (Python) / higher-order function (Node) — wrap a whole entrypoint | a chain |
session | A block you open around some work | an agent |
agent | A session recorded as an agent — a dynamic, LLM-driven loop | an agent |
chain | A session recorded as a chain — a fixed, linear pipeline | a chain |
"Recorded as" is just the label the run carries in the dashboard: agent for work that
decides its own next step, chain for a fixed pipeline. It changes nothing about behaviour —
pass kind="agent" / kind="chain" to workflow or session (or use agent/chain) if the
default label doesn't fit the shape of your code.
Reach for workflow first — it's the least intrusive. Use session / agent / chain when you
want to wrap a region of code rather than a whole function, or to be explicit about whether the
run is a dynamic agent or a linear chain.
workflow
Wrap the function that handles one request or run. Every LLM call inside is tagged.
import token_police as tp
@tp.workflow(name="support_agent")
def handle(user_id: str, paid_plan: str, session_id: str, query: str):
... # all LLM calls here share one trace + this identityNode — same idea, as a wrapper:
import * as tp from "token-police";
const handle = tp.workflow({ name: "support_agent" }, async (req) => {
// ...
});session / agent / chain
Open a block around the work instead of wrapping a function:
with tp.session(name="nightly_report", user_id="batch", kind="chain"):
... # calls in here belong to this sessionawait tp.session({ name: "nightly_report", userId: "batch" }, async () => {
// ...
});tp.agent(...) and tp.chain(...) are the same call with the anchor fixed — use agent for a
loop that decides its own steps, chain for a set pipeline. Nested scopes inherit the parent's
session_id and trace unless you pass new values.
Dynamic identity: bind_args
Often you don't know user_id or session_id until the request arrives. workflow can read them
straight off your function's arguments — on by default.
- Python (
bind_args=True) inspects the function signature and picks up arguments nameduser_id,paid_plan,session_id,workflow_name, ormetadata. Each is used only if it's the right type (strings;metadataa dict); otherwise the static decorator value stands. Any binding failure falls back silently to the static values. - Node (
bindArgs: true) readsuserId,paidPlan,sessionIdandmetadataoff the first argument when it's a plain object (JS has no signature introspection). The snake_case spellingsuser_id,paid_planandsession_idare accepted too. A plain-string first argument is deliberately not auto-bound.
# user_id + session_id come from the call, not hard-coded
@tp.workflow(name="chat")
def chat(user_id: str, session_id: str, message: str): ...To thread a multi-turn conversation into one trace, pass the same session_id on every turn.
The full contract is on Identity.

