Sessions
ArmorIQSession is the primitive every framework adapter is built on - the plan/enforce/report lifecycle, the three enforcement modes, and how holds become human approvals.
Sessions
ArmorIQSession is the primitive that carries one agent turn. It holds the intent
token minted for that turn and the step index that ties every policy decision and
audit record back to the same plan.
Every shipped adapter - Strands, LangChain, Google ADK - is a thin wrapper over this class. If your framework isn't supported, this is the surface you build against.
from armoriq_sdk import ArmorIQClient, SessionOptions
client = ArmorIQClient(api_key="ak_live_...")
session = client.start_session(SessionOptions(mode="proxy"))import { ArmorIQClient } from '@armoriq/sdk';
const client = new ArmorIQClient({ apiKey: 'ak_live_...' });
const session = client.startSession({ mode: 'proxy' });For multi-user agents, scope.start_session() / scope.startSession() on an
ArmorIQUserScope returns a session already bound
to that user.
Choosing a mode
Read this before anything else. mode is the only session option that changes
what the SDK does, and the default cannot perform human approvals.
mode | Where the decision is made | On a hold | Approvals possible? |
|---|---|---|---|
local (default) | In-process. Verifies plan binding and the policy snapshot. No network call for the decision. | check() rewrites it to block and appends "switch ARMORIQ_MODE=proxy to enable approval workflows for this action" to the reason. | No |
sdk | POST {backend}/iap/sdk/enforce | Creates a delegation request and returns action: "hold" with a delegation_id. | Yes |
proxy | POST {proxy}/invoke with enforce_only=true | Same - delegation created, hold returned with a delegation_id. | Yes |
The default mode silently downgrades holds to blocks. If you build on
defaults and expect a policy's hold to reach an approver, you will instead
get action: "block" with no approval request created anywhere. The only
signal is the appended text in reason. Use sdk or proxy if your policies
use hold.
local is the right default for observe-mode integrations where the framework
calls the MCP server itself and you want a fast in-process decision. Choose
proxy when you also want ArmorIQ to make the tool call for you - see
dispatch below.
The lifecycle
Four steps. The fourth is the one integrations most often miss.
flowchart LR
A["1. start_plan<br/>capture the plan, mint token"] --> B["2. check<br/>decide per call"]
B --> C["3. report<br/>audit the execution"]
C --> D["4. complete_plan<br/>close the plan"]
C -.->|next tool call| B1. start_plan
Captures every tool call the model chose for this turn, and mints the intent token
that binds them. Nothing else on the session works before this - check,
enforce*, and dispatch all raise RuntimeError / throw if the token is
missing.
token = session.start_plan(
[{"name": "travel__book_flight", "args": {"dest": "SFO"}}],
goal="Book the cheapest flight to SFO",
)const token = await session.startPlan(
[{ name: 'travel__book_flight', args: { dest: 'SFO' } }],
'Book the cheapest flight to SFO',
);A tool call that is not in the plan is refused at check time with
tool-not-in-plan, regardless of policy. Calling start_plan again closes the
previous plan's trace and starts a new one: one plan, one trace.
2. check
The mode-aware decision. Call it once per tool call, before executing it.
decision = session.check("travel__book_flight", {"dest": "SFO"})
if decision.action == "hold":
... # see Holds and approvals below
elif not decision.allowed:
raise PermissionError(decision.reason)const decision = await session.check('travel__book_flight', { dest: 'SFO' });
if (decision.action === 'hold') {
// see Holds and approvals below
} else if (!decision.allowed) {
throw new Error(decision.reason);
}check returns an EnforceResult - it does not raise on a deny. A blocked
call is a normal return value with allowed: false, so an integration that
ignores the return value executes the tool anyway.
EnforceResult
| Field (Python / TypeScript) | Meaning |
|---|---|
allowed | True only for an explicit allow. Everything else, including errors, is False. |
action | "allow" · "block" · "hold" |
reason | Human-readable explanation. Carries the mode caveat in local. |
delegation_id / delegationId | Set on a hold. Pass it to await_approval. |
matched_policy / matchedPolicy | Name of the governing policy, when the backend reports one. |
obligations | Metadata explaining why a gating decision landed. Empty list when none. |
Every enforcement path fails closed: a network error, a non-2xx response, or
an ambiguous body all produce allowed: false, action: "block" with the reason
prefixed enforce-unavailable or enforce-rejected.
Calling a transport directly
check dispatches on mode. You can also call a transport explicitly, which
bypasses the mode setting entirely:
| Method (Python / TypeScript) | Transport |
|---|---|
enforce_local / enforceLocal | In-process plan-binding and policy-snapshot check |
enforce_sdk / enforceSdk | POST {backend}/iap/sdk/enforce |
enforce / enforce | POST {proxy}/invoke with enforce_only=true |
enforce is not a generic entry point despite the name - it is the
proxy transport specifically. check is the one that respects mode. Note
also that enforce_local never applies the hold-to-block rewrite; that
happens in check. Calling enforce_local directly can therefore hand you a
raw hold that nothing will ever approve.
3. report
Records what actually happened, after your code has executed the tool. This is what populates the audit trail and the tool spans in Observability.
from armoriq_sdk import ReportOptions
result = call_the_mcp_server(...)
session.report(
"travel__book_flight",
{"dest": "SFO"},
result,
ReportOptions(status="success", duration_ms=412.0),
)const result = await callTheMcpServer(...);
await session.report(
'travel__book_flight',
{ dest: 'SFO' },
result,
{ status: 'success', durationMs: 412 },
);ReportOptions: status ("success" · "failed" · "error", default
"success"), error_message / errorMessage, duration_ms / durationMs,
is_delegated / isDelegated, delegated_by / delegatedBy,
delegated_to / delegatedTo.
report never raises, and returns nothing. If the audit POST fails it
logs a warning and returns normally, and the step index advances either way.
A clean return is not evidence the audit landed - check your logs or the
Observability dashboard.
Call report for failures too, with status="failed" and an
error_message. A tool call that was allowed and then threw is exactly what an
auditor needs to see.
4. Complete the plan
The session does not do this for you. Nothing in ArmorIQSession marks a
plan completed - report closes a step, not the plan. A plan you never
complete stays open indefinitely in Plans Governance.
client.complete_plan(token.plan_id)await client.completePlan(token.planId);This is a client method, not a session method. If you are writing an adapter, put it wherever your framework signals end-of-turn.
Holds and approvals
A hold means policy requires a human decision. The SDK creates a delegation
request and hands back its id without blocking - you then wait on the
decision yourself.
# await_approval is async - everything else on the session is sync.
async def run_transfer():
decision = session.check("payments__transfer", {"amount": 5000})
if decision.action == "hold":
outcome = await session.await_approval(decision.delegation_id, timeout=600)
if outcome == "approved":
result = call_the_mcp_server(...)
session.report("payments__transfer", {"amount": 5000}, result)
elif outcome == "rejected":
... # an approver said no
else:
... # "timeout" - still undecidedconst decision = await session.check('payments__transfer', { amount: 5000 });
if (decision.action === 'hold') {
const outcome = await session.awaitApproval(decision.delegationId!, { timeout: 600 });
if (outcome === 'approved') {
const result = await callTheMcpServer(...);
await session.report('payments__transfer', { amount: 5000 }, result);
} else if (outcome === 'rejected') {
// an approver said no
} else {
// 'timeout' - still undecided
}
}await_approval / awaitApproval returns one of three strings -
"approved", "rejected", "timeout". It does not raise on rejection and does
not return a boolean, so if outcome: is always true and is a bug.
Things worth knowing before you tune it:
- Defaults are
timeout=300seconds andinterval=5seconds. - It sleeps before its first poll. Even an already-approved delegation takes
at least one
intervalto resolve, so the floor is ~5s at defaults. "timeout"is not a decision. The delegation is still pending; the approver may act later. Treat it as unresolved, not denied.- Transient failures keep waiting.
429,502,503,504back off and retry. Any other error propagates - fail closed. - Approvals are plan-scoped. An approval granted under a different plan will not satisfy this hold. A still-valid approval for this plan is reused automatically, so the user isn't asked twice.
On approval the delegation is marked executed for you.
Proxy mode: dispatch
In proxy mode you can let ArmorIQ make the call. dispatch enforces and
invokes in one step, returning the tool's raw result.
session.start_plan([{"name": "travel__book_flight", "args": {"dest": "SFO"}}])
result = session.dispatch("travel__book_flight", {"dest": "SFO"})await session.startPlan([{ name: 'travel__book_flight', args: { dest: 'SFO' } }]);
const result = await session.dispatch('travel__book_flight', { dest: 'SFO' });dispatch advances the step index itself, so do not also call report for the
same call. It always routes through the proxy regardless of mode.
Introspection and cleanup
| Purpose | Python | TypeScript |
|---|---|---|
| Active mode | current_mode | currentMode |
| Current intent token | current_token | currentTokenValue |
| Clear per-request state | reset() | reset() |
| Flush observability | flush_observability() | flushObservability() |
| Release resources | close() / dispose() | await close() / await dispose() |
The current-token accessor is not named symmetrically: current_token in
Python, currentTokenValue in TypeScript. currentToken does not exist on
the TypeScript class.
Call reset() between turns on a session you intend to reuse - it clears the
token and step state without tearing down observability. Python also supports
the context-manager form, which closes the session for you:
with client.start_session(SessionOptions(mode="proxy")) as session:
session.start_plan(tool_calls)
...There is no TypeScript equivalent - call await session.close() yourself.
Member reference
Verified against both SDKs at 0.6.7.
| Member | Python | TypeScript |
|---|---|---|
| Session class | ArmorIQSession | ArmorIQSession |
| Options | SessionOptions | SessionOptions |
| Mode type | SessionMode | SessionMode |
| Decision | EnforceResult | EnforceResult |
| Report options | ReportOptions | ReportOptions |
| Capture plan | start_plan | startPlan |
| Mode-aware decision | check | check |
| Local transport | enforce_local | enforceLocal |
| Backend transport | enforce_sdk | enforceSdk |
| Proxy transport | enforce | enforce |
| Audit an execution | report | report |
| Enforce and invoke | dispatch | dispatch |
| Wait on a hold | await_approval | awaitApproval |
| Clear state | reset | reset |
| Active mode | current_mode | currentMode |
| Current token | current_token | currentTokenValue |
| Context manager | with ... as session | not available |
| Reanchor flush | not available | flushReanchor |
SessionOptions fields are snake_case in Python and camelCase in TypeScript:
tool_name_parser/toolNameParser, default_mcp_name/defaultMcpName,
validity_seconds/validitySeconds (default 3600), llm (default
"agent"), mode (default "local"), observability,
session_id/sessionId, context_id/contextId. TypeScript additionally
accepts trueReanchor and reanchorGranularity.
Related
- Client initialization - creating the client and user scopes
- Error handling - exceptions the surrounding code still has to catch
- Observability - the traces and spans these calls emit
- Integrations - the adapters built on this surface