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.

modeWhere the decision is madeOn a holdApprovals 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
sdkPOST {backend}/iap/sdk/enforceCreates a delegation request and returns action: "hold" with a delegation_id.Yes
proxyPOST {proxy}/invoke with enforce_only=trueSame - 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| B

1. 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
allowedTrue only for an explicit allow. Everything else, including errors, is False.
action"allow" · "block" · "hold"
reasonHuman-readable explanation. Carries the mode caveat in local.
delegation_id / delegationIdSet on a hold. Pass it to await_approval.
matched_policy / matchedPolicyName of the governing policy, when the backend reports one.
obligationsMetadata 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 / enforceLocalIn-process plan-binding and policy-snapshot check
enforce_sdk / enforceSdkPOST {backend}/iap/sdk/enforce
enforce / enforcePOST {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 undecided
const 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=300 seconds and interval=5 seconds.
  • It sleeps before its first poll. Even an already-approved delegation takes at least one interval to 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, 504 back 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

PurposePythonTypeScript
Active modecurrent_modecurrentMode
Current intent tokencurrent_tokencurrentTokenValue
Clear per-request statereset()reset()
Flush observabilityflush_observability()flushObservability()
Release resourcesclose() / 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.

MemberPythonTypeScript
Session classArmorIQSessionArmorIQSession
OptionsSessionOptionsSessionOptions
Mode typeSessionModeSessionMode
DecisionEnforceResultEnforceResult
Report optionsReportOptionsReportOptions
Capture planstart_planstartPlan
Mode-aware decisioncheckcheck
Local transportenforce_localenforceLocal
Backend transportenforce_sdkenforceSdk
Proxy transportenforceenforce
Audit an executionreportreport
Enforce and invokedispatchdispatch
Wait on a holdawait_approvalawaitApproval
Clear stateresetreset
Active modecurrent_modecurrentMode
Current tokencurrent_tokencurrentTokenValue
Context managerwith ... as sessionnot available
Reanchor flushnot availableflushReanchor

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.

On this page