Skip to content

aimon-core integration guide — following aimon-cli as the reference

Walks through how aimon-cli assembles aimon-core, line by line, and explains how to port the same patterns into your own application.

Where this document sits

The following documents already exist. They have different purposes, so read them together.

Document Purpose
architecture.en.md Reference for aimon-core's core abstractions (Tool, LlmClient, VirtualFileSystem, ...)
embedding-agent-in-application.en.md The recommended patterns for Spring Boot/SDK embedding, scope policy, multi-session
agent-session-guide.en.md How to use the LiveSession API and event streaming
scope-model.en.md The normative document for lifetime, ownership and teardown — every scope statement here follows it
This document Follows aimon-cli's actual bootstrap code line by line and explains why it was assembled that way

aimon-cli is the most complete reference implementation: it plugs in every extension point the core offers (LLM, filesystem, tools, skills, hooks, scheduling, MCP) in a single place. This document reads that code as-is and points out the decisions you face when moving it into your own host application.


Table of contents

  1. The big picture
  2. Module dependencies and build setup
  3. Bootstrap flow — AimonCli.call()
  4. AgentSetupFactory.create() line by line
  5. Adaptation guide, component by component
  6. Lifecycle and scopes
  7. Minimal embedding example
  8. Moving to a web application
  9. Other adaptation scenarios
  10. Checklist

1. The big picture

aimon-cli works in three large stages.

[Picocli entry point]       [Factory bootstrap]                   [Session execution]
AimonCli.call()      ──▶    AgentSetupFactory.create(config)  ──▶ ReplSession.start()
- parse options             - create the LLM client               - LiveSession.submit(input)
- load configuration        - initialise VirtualFileSystem        - subscribe to events
- open AgentSetup           - register Tool/Skill/Hook            - queue user input
- AgentSetup.close()        - start SchedulingEngine
                            - create LiveSession

Your application follows the same three stages. Only swap the Picocli entry point for your own entry point (a web handler, a batch job, ...) and the REPL for your own interaction loop. The bootstrap stage in the middle carries over almost unchanged.

Layer diagram:

┌─────────────────────────────────────────────────────────────┐
│  Application                                                │
│  AimonCli (CLI)  /  HTTP handler  /  Batch job              │
└──────────────────────────────┬──────────────────────────────┘
                               │ create(CliConfig)
┌──────────────────────────────▼──────────────────────────────┐
│  Composition root (AgentSetupFactory)                       │
│  LlmClient + VirtualFileSystem + AgentBundle +              │
│  ToolRegistry + SkillRegistry + HookRegistry +              │
│  SchedulingEngine + AgentRuntime +                          │
│  AgentExecutor + SessionRecordStore + LiveSession           │
└──────┬────────────┬──────────────┬──────────────┬───────────┘
       │            │              │              │
┌──────▼──┐    ┌────▼────┐   ┌─────▼──────┐  ┌────▼─────────┐
│ aimon-  │    │ aimon-  │   │ aimon-     │  │ aimon-       │
│ llm-*   │    │ filesys │   │ scheduling │  │ knowledge-*  │
└─────────┘    │ -*      │   │ -quartz    │  └──────────────┘
               └─────────┘   └────────────┘

aimon-core defines interfaces only. The actual implementations are pulled in from separate modules and assembled — that is the central pattern aimon-cli demonstrates.


2. Module dependencies and build setup

aimon-cli's build.gradle.kts

modules/aimon-cli/build.gradle.kts:

plugins {
    `java-library`
    application
}

application {
    mainClass.set("at.aimon.cli.AimonCli")
}

dependencies {
    // Core module (interfaces + the Orca executor)
    implementation(project(":aimon-core"))

    // LLM implementations — pick only the ones you need
    implementation(project(":aimon-llm-anthropic"))
    implementation(project(":aimon-llm-openai"))

    // CLI only (not needed in your own application)
    implementation(libs.picocli)
    implementation(libs.jline)
    implementation(libs.jansi)

    // Configuration parsing (optional)
    implementation(libs.jackson.databind)
    implementation(libs.jackson.dataformat.yaml)
    implementation(libs.snakeyaml)

    // Logging
    implementation(libs.logback.classic)
    implementation(libs.slf4j.api)
}

Dependencies for your own application

Minimal setup:

dependencies {
    implementation(project(":aimon-core"))      // or the published artifact
    implementation(project(":aimon-llm-openai")) // at least one LLM
}

Add as needed:

Module When you need it
aimon-llm-openai / aimon-llm-anthropic LLM calls (at least one is required)
aimon-filesystem-gridfs MongoDB GridFS as the virtual filesystem backend
aimon-filesystem-s3 S3/MinIO as the backend
aimon-scheduling-quartz If you need task scheduling
aimon-knowledge-opensearch A vector-search-backed KnowledgeStore
at.aimon.sandbox:aimon-sandbox-docker / -kubernetes (separate repository) If you need tools that run commands isolated in a container/pod
at.aimon.browser:aimon-browser-playwright (separate repository) If you need browser automation tools

Module dependency rule (.claude/rules/architecture.md): implementation modules reference the core only through implementation(project(":aimon-core")). They do not expose it with api() — that would leak core types as a transitive dependency.


3. Bootstrap flow — AimonCli.call()

modules/aimon-cli/src/main/java/at/aimon/cli/AimonCli.java:67-136

public Integer call() {
    // (1) Load the configuration
    CliConfigLoader configLoader = new CliConfigLoader();
    CliConfig config = loadConfiguration(configLoader);

    // (2) Fold the CLI options into the configuration (streaming toggle, initial budget)
    if (streaming != null) {
        config.getCliSettings().setStreaming(streaming);
    }
    ExecutionBudget initialBudget = buildInitialBudget();

    // (3) Open the AgentSetup (try-with-resources)
    AgentSetupFactory agentFactory = new AgentSetupFactory();
    try (AgentSetupFactory.AgentSetup agentSetup = agentFactory.create(config)) {

        // (4) Start the interaction loop — a REPL in the CLI
        ReplSession replSession = new ReplSession(agentSetup, cliSettings, initialBudget);
        replSession.start();
    }
    return 0;
}

The mapping when you port this into your own application:

Stage in AimonCli Your code
configLoader.load(path) Spring @ConfigurationProperties, env-vars, your own YAML parser, ...
factory.create(config) Keep it — either call AgentSetupFactory directly, or move its internals into your own composition root
try-with-resources Delegate the lifecycle to Spring @Bean(destroyMethod = "close") / Quarkus @PreDestroy / ...
replSession.start() An HTTP handler, a batch job, a WebSocket receive loop

3.1 ${VAR} — where it is expanded

In every scalar value and every mapping key of the configuration file. ${NAME} is replaced by the value of the environment variable NAME, and a variable that is not set fails startup naming both the variable and the key it was written onEnvironment variable not set: OPENAI_KEY (at memory.dreamer.scorer.embedding.apiKey). There is no list of participating fields. While there was one it held three string fields of llm plus the capability map keys, and when the memory block gained a credential of its own nobody extended it — so the CLI's own shipped default-config.yaml demonstrated apiKey: "${OPENAI_KEY}" in a block the loader never visited.

llm:
  apiKey: "${OPENAI_API_KEY}"     # a string
  timeout: "${LLM_TIMEOUT}"       # an integer -- expansion runs before binding, so this works too
  anthropic:
    thinkingMode: "${MODE}"       # an enum -- likewise
  modelCapabilities:
    ${DEPLOYMENT}:                # a mapping key
      supportsSamplingParameters: false
mcp:
  servers:
    - command: "npx"
      args: ["-y", "${PACKAGE}"]  # an array element
memory:
  storagePath: "${HOME}/memory/representations.jsonl"   # part of a value is fine

The other half of the rule:

  • Two sibling keys that expand to the same name are refused. yaml stops you writing the same key twice, but it cannot stop ${A} and ${B} from expanding to one value, and nobody reports it when the later one wins.
  • Expansion is a single pass. If a variable's value is itself ${OTHER}, it stays literal.
  • There is no escape for writing a literal ${.
  • A scalar carrying no placeholder is never touched. The text the parser read reaches the deserializer as written, so thinkingMode: off still means offoff is a YAML 1.1 boolean, and only the written text tells it apart from no and false.

Expansion happens on the token stream before Jackson binds anything (at.aimon.cli.config.PlaceholderExpandingParser). That is why the starter and the CLI now give the same answer about when placeholders resolve: Spring resolves them before binding, and so does the CLI.


4. AgentSetupFactory.create() line by line

modules/aimon-cli/src/main/java/at/aimon/cli/factory/AgentSetupFactory.java:687-902

This one method contains every assembly pattern in aimon-core. We go through it stage by stage.

The line numbers quoted below are only a snapshot of that moment, and this file keeps growing (tracing, peer memory, dreamer, rewake, session checkpoints and GraalJS arrived one after another). If the numbers no longer line up, find things by method name — every helper this document references is prefixed create* / build* / configure* / register*.

4.1 Creating the LLM client (line 690)

final LlmClient llmClient = createLlmClient(config);

Internally LlmClientFactory.create() branches on the provider string (LlmClientFactory.java:16-28):

return switch (provider) {
    case "anthropic" -> createAnthropicClient(config);
    case "openai"    -> createOpenAIClient(config);
    default          -> throw new ConfigurationException("Unsupported LLM provider: " + provider);
};

Each builder constructs the SDK-specific configuration object (AnthropicConfig, OpenAIConfig) and injects apiKey, model, timeout and baseUrl. Both sides add two more — when llm.modelCapabilities is present they build a model capability registry from it and pass that to modelCapabilityRegistry(...), and when llm.reasoningEffort is present they pass it straight through (anthropicConfig(...) · openAiConfig(...)).

Switching providers — change agent.name too

The model name each agent request carries comes from the agent definition, not from the llm: block. agent.name selects the classpath bundle agents/<name>/, and both clients send modelConfig.getName().orElse(config.getModel()) — so a definition's model.name, when it has one, wins over llm.model, and every bundled definition has one. The shipped agent.name: default names OpenAI models (gpt-5.6-terra for the main agent, gpt-5.1 for its explore subagent). Edit only the llm: block to Anthropic and those names go to Anthropic, whose Messages API answered gpt-5.6-terra with HTTP 404 not_found_error on 2026-09-10.

This is the configuration switched to Anthropic:

llm:
  provider: anthropic
  apiKey: "${ANTHROPIC_KEY}"
  model: claude-sonnet-4-5
agent:
  name: default-anthropic

The bundles' explore subagents name no model and run on their main agent's — claude-sonnet-4-5 in default-anthropic (the only explore that names one is default's, and it names gpt-5.1). A subagent that names no model runs on what the main agent runs on, and when the main agent's definition names none either, on the client's default model: llm.model when it is set, and when it is not — which only anthropic allows — the Anthropic client's built-in default model.

Five keys change together:

  • provider
  • apiKey — that vendor's key
  • baseUrl — remove it. The shipped file sets OpenAI's host, and a leftover one keeps every request on that host
  • model — not the agent's model, but the places in the next paragraph still use it, so an OpenAI name left here is what memory and wiki generation send to Anthropic
  • agent.name

llm.model still reaches peer memory (the dialectic engine, deriver and reconciler, plus the dreamer and its LLM judge unless memory.dreamer.scorer.llm.model is set) and wiki page generation. A definition without model.name runs on it. Under anthropic it may be left out — memory, wiki generation and a definition without model.name then run on the Anthropic client's default model, and with memory enabled, startup prints one line naming that model. In the startup banner, the Agent bundle: line names the bundle that loaded, and the parentheses in LLM Provider: <provider> (<model>) are the model the main agent's requests carry — a subagent's own model is not shown.

When a loaded definition names the other vendor's models, startup warns — it never stops. For the shipped default agent under provider: anthropic, that happens when baseUrl is absent, on Anthropic's host, or still on OpenAI's host from the shipped file. Behind any other baseUrl it is silent, and so it is on names neither vendor claims, such as a gateway's own deployment name. The warning prints on the terminal before the banner and goes to ~/.aimon/logs/aimon.log. Each line names the definition's key (model.name for the main agent, model for a subagent) and where it was read: a bundle file as classpath, a user subagent as its absolute path under the CLI's working directory. That directory is the jar's directory, or user.dir when not running from a jar (modules/aimon-cli under ./gradlew :aimon-cli:run), and it is the banner's Working Directory: line. It offers only remedies that change something.

How hard the model should think — llm.reasoningEffort

llm:
  provider: openai
  model: gpt-5.1
  reasoningEffort: medium      # none | minimal | low | medium | high -- case-insensitive

Unlike the llm.anthropic block, both providers read this one. The name is the neutral SPI type's own (at.aimon.core.llm.ReasoningEffort), and "how much deliberation should this call spend" does not mean something different per vendor. What each does with the answer differs — OpenAI sends a rung parameter, Anthropic translates it to a token budget — and that translation is what a neutral enum is for.

An agent definition's model.reasoningEffort wins over this one. A rung that is not on this model's ladder is omitted and reported, never raised to the nearest one it has: a clamp is a request the operator did not make, and it would arrive silently.

On Anthropic it needs llm.anthropic.thinkingMode to be something other than the default off. Under off the request carries no thinking parameter at all, so the effort reaches nothing — and the client says so once per process. reasoningEffort: none is the exception: it and off mean the same thing, so nothing is said.

When a gateway calls the model something else — llm.modelCapabilities

Point baseUrl at an Azure deployment or a vendor-compatible gateway and that gateway may expose a model under a name of its own (gpt-5-mini as prod-assistant). The built-in capability table knows models by their real names, so that name does not match it and falls through to the fail-open path — which means temperature is sent to a model that does not take it, and the request answers HTTP 400. This block is where you say what that name actually accepts.

Both providers read this block, and the built-in table describes both vendors — the gpt-* / o* rows and the claude-* models that refuse the sampling parameters (six measured, plus the documentation-derived claude-mythos family). So provider in the example below may just as well be anthropic.

llm:
  provider: openai
  baseUrl: https://gateway.internal/v1
  apiKey: "${OPENAI_KEY}"
  model: prod-assistant
  modelCapabilities:
    prod-assistant:
      supportsSamplingParameters: false

The map key is the same name model carries, and it is matched ignoring case (write Prod-Assistant and a look-up for prod-assistant still finds it). ${VAR} is resolved in it too, so a deployment writing model: ${DEPLOYMENT} can describe its own model.

There are eight flags and every one is optional. What you leave out keeps ModelCapabilities.unknown()'s value. For a name the built-in table does not know — a renamed gateway deployment, like the example above — that is today's behaviour, which is why the single line above is a complete answer to the 400. For a name it does know (claude-sonnet-5, gpt-5-mini) it is not — read the "an entry is that name's whole row" paragraph below the two tables. They are not all required because a gateway operator who knows that temperature earns a 400 does not know whether the model replays reasoning traces, and filling that box in anyway turns the 400 into a 404 on a gateway that has no /v1/responses.

Key What it says Left out
supportsSamplingParameters whether temperature / top_p / the two penalties may be set true — a value the caller set is sent
supportsReasoningEffort whether a reasoning-effort parameter is on this model's request surface false — the framework does not invent one
supportsToolsWithReasoning whether tools and a non-NONE effort may share one request true — nothing is narrowed without evidence
supportsReasoningTraceRoundTrip whether reasoning traces must be replayed for the reasoning to survive false — stays on the Chat Completions path
lowestReasoningEffort where this model's effort ladder starts (nonehigh) — meaning it takes that rung and every one above it minimal through high
acceptedReasoningEfforts every rung it takes, as a list — the general form, for a ladder with a gap in it ([none, low, medium, high]). An empty element (~, or a bare -) is not skipped: it fails startup naming the position same as the row above
supportsReasoningSummary whether this model accepts a reasoning-summary request (reasoning.summary). Read on the OpenAI Responses path only — a name reaches that path only when supportsReasoningTraceRoundTrip: true, so this is where you describe a gateway that takes reasoning.effort and 400s on reasoning.summary true — what the caller asked for is not taken away
thinkingDialect which shape this model's thinking request takes — unknown · either · budgeted · adaptive. Read by the anthropic branch only. This is the value llm.anthropic.thinkingMode: auto below goes and asks for unknown — the table cannot answer, and the client leaves whatever was configured as it was

A declaration extends the built-in table rather than replacing it. It is registered as an exact entry, so the existing exact > prefix rule is what makes the operator's entry win — and the win is one name wide: declaring gpt-5 changes exactly that name, while gpt-5-mini is still answered by the built-in gpt-5 prefix. There is no way to declare a prefix from configuration: prefix precedence is registration order, and a yaml file's line order is not the place to keep that.

If you are describing a name the built-in table already carries, the entry is that name's whole row. Extending the table and patching one row are different things. An entry is the whole row for its name, so a flag you leave out falls back to its fail-open value rather than to what that row said. For a name the built-in table does not know there is no difference — there is no row to shadow. For a name it does know there is, and the claude-* rows are the case: each states two things, the dialect and the sampling suppression.

# Wrong — the suppression comes back
modelCapabilities:
  claude-sonnet-5:
    thinkingDialect: unknown

# Right — restate every flag that row stated
modelCapabilities:
  claude-sonnet-5:
    thinkingDialect: unknown             # or adaptive / budgeted
    supportsSamplingParameters: false    # copied from the built-in row — not optional here

The first form puts supportsSamplingParameters back at its fail-open true, so temperature goes to a model that answers 400 to it — and with no warning, because the suppression WARN fires only when the flag is false. thinkingMode: extended is no escape either: that branch omits temperature and still sets top_p.

The rule is one line — the entry is the whole row, so copy every flag the built-in row states. For a claude-* name that is the two above.

The two ladder keys are mutually exclusive. lowestReasoningEffort is shorthand for the common case — "it starts here and runs to the top" — while acceptedReasoningEfforts is for a ladder with a gap in the middle, which the built-in gpt-5.6-terra row is the measured instance of (it takes none and rejects minimal). Writing both fails at startup, because there is no way to answer which one was meant.

What is not silently ignored — an unknown flag name, an unusable lowestReasoningEffort value, an entry stating both ladder keys, an empty acceptedReasoningEfforts list, an entry that declares nothing, a blank or space-padded name, two names differing only in case, and two ${VAR} keys that expand to the same name. All of them are a ConfigurationException whose message names the yaml key to fix. A declaration under provider: anthropic is no longer refused — that branch reads this registry too.

The starter property on the same axis is in embedding-agent-in-application.en.md. The two spellings do not mix — camelCase for the CLI, kebab-case for the starter.

Tuning Anthropic's thinking — llm.anthropic

Unlike the block just above, this one is read by the anthropic branch alone. All three key names carry Anthropic's own vocabulary — "thinking" is that vendor's word for what this repository elsewhere calls ReasoningEffort / ReasoningTrace, budget_tokens is a literal field of the request body, and a "thinking block" is a signed content block on its wire. So a block written under provider: openai is not ignored: it fails at startup.

Leave this block out and the request does not change by a single byte. Capturing and replaying thinking blocks happens regardless of this setting, so a deployment on the newest models — where thinking is on by default — already has that benefit with nothing configured. What this block opens up is tuning: choosing a dialect, setting a budget, turning replay off.

llm:
  provider: anthropic
  apiKey: "${ANTHROPIC_KEY}"
  model: claude-sonnet-5
  anthropic:
    thinkingMode: auto
    replayThinkingBlocks: true
Key Meaning If omitted
thinkingMode Which thinking request shape to send (the four values below) off — no thinking parameter is sent
thinkingBudgetTokens An explicit budget_tokens for the extended dialect Derived from the call's reasoning effort
thinkingDisplay Whether to ask for the model's thinking text and stream it (summarized) Neither is asked for and nothing is streamed
replayThinkingBlocks Whether stored thinking blocks are replayed on the next request true — they are replayed

The four values of thinkingMode. Case does not matter.

Value What it sends
off (default) No thinking parameter. This does not mean the model will not think — on the newest models it will, and those blocks are still captured and replayed
extended thinking: {"type": "enabled", "budget_tokens": N} — the budget dialect
adaptive thinking: {"type": "adaptive"} plus output_config.effort — the current generation's dialect
auto Whichever dialect the capability table says this model speaks. The value that lets one setting serve a deployment running several Claude models

On the three families the built-in table marks BUDGETED (claude-opus-4-5 · claude-sonnet-4-5 · claude-haiku-4-5), auto sends the budget dialect, and the budget comes from the call's ReasoningEffort — the middle rung, 4096, when none is set. That budget meets the same ceiling as thinkingBudgetTokens below. When the agent definition sets no model.maxTokens, max_tokens is AnthropicConfig's default of 4096, so the budget is clamped to 4095 with one WARN, leaving one token for the visible answer. Every agent definition bundled with the CLI sets maxTokens: 40000, so none of them meets this. There are two remedies — raise the agent definition's model.maxTokens, or lower llm.reasoningEffort above to low (2048) or minimal (1024) (or the agent definition's model.reasoningEffort, if it sets one). The warning names only the first. This is a decision, not an oversight; the reasons and the alternatives refused are in anthropic-thinking.md §6.2.

The two dialects are mutually exclusive per model and sending the wrong one is an HTTP 400. That is why auto exists, and it is also auto's limit — against a model the table cannot name it sends nothing and warns. If you rename models behind a gateway, declaring that name's thinkingDialect in the llm.modelCapabilities block just above is the whole remedy, and this is exactly where the two blocks meet.

llm:
  provider: anthropic
  baseUrl: https://gateway.internal
  model: prod-claude
  anthropic:
    thinkingMode: auto
  modelCapabilities:
    prod-claude:
      thinkingDialect: adaptive        # write what you measured. If you do not know, unknown is the honest answer

Writing the same thing for a name the built-in table does know (claude-sonnet-5 and friends) means writing supportsSamplingParameters: false beside it — the entry is the whole row, and the "an entry is that name's whole row" paragraph in the llm.modelCapabilities section above is why.

thinkingBudgetTokens is not an independent knob; it belongs to extended. Written together with auto, adaptive or the default off it fails at startup rather than being quietly ignored. The refusal under auto is what AnthropicThinkingMode.AUTO states: a number has no meaning until the dialect is known, and under auto it is not known until the request is built. The likeliest of the three mistakes is the third — writing the budget and leaving the mode out. The mode is then its default off, so the number reaches nothing, and that too is a startup failure.

The value itself has a floor and a ceiling.

  • A floor of 1024. The API rejects a smaller budget on every request, so it is rejected at startup instead.
  • A ceiling below max_tokens. Thinking tokens count against max_tokens, so the client clamps to max_tokens - 1 and says so at WARN. That max_tokens is the agent definition's model.maxTokens, and AnthropicConfig's default of 4096 when it sets none — so on such an agent thinkingBudgetTokens: 8000 goes out as 4095. Every agent definition bundled with the CLI sets maxTokens: 40000, so on those the budget goes out as 8000. There is no key here for raising that ceiling — it is the agent definition's model.maxTokens, a third configuration surface.

Under auto and adaptive, how much thinking to do comes from the call's ReasoningEffort, and the key that writes it per deployment is the shared llm.reasoningEffort above.

thinkingDisplay is one key doing two things, and which one bites depends on the dialect. Under adaptive it writes thinking.display on the request — without which this model generation omits the text entirely — and at the same time opens the gate that streams that text to the user. Under extended the deltas already arrive, so it only opens the gate and no display is sent; the client says so once at WARN. Under the default off nothing reaches it, and it says so once there too. There is one value, summarized — the other value the server accepts, omitted, is its own default, so writing it behaves exactly like leaving the key out, and because this key's other half opens the streaming gate, omitted would mean "open the channel and put nothing in it". So it is not offered.

The REPL prints that text dimmed and distinct from the answer, opened by a [thinking] marker. Leave it out and the request and the screen are what they were before this key existed — it spends output tokens on every request, which is why it is opt-in.

replayThinkingBlocks: false is an escape hatch for one named failure — "Invalid signature in thinking block. The block is bound to a different conversation." A signature stays valid only while the system prompt, the tools and the messages before it are unchanged, and AIMON re-renders its system prompt every iteration and compacts client-side. The vendor's own remedy is to strip every thinking block from the history, which is what false does. The cost is the feature itself: the model re-derives its reasoning each turn.

${VAR} is expanded inside this block too — including onto an enum. Expansion runs on the token stream before binding, so it does not depend on a field's Java type. The rule is in §3.1, and on this axis the starter and the CLI now give the same answer.

The starter properties on this axis are in embedding-agent-in-application.en.md. The spellings do not mix here either — thinkingMode on the CLI, thinking-mode in the starter.

The OpenAI-only block — llm.openai

The counterpart of llm.anthropic, following the same rule. The openai branch alone reads it, so this block written under provider: anthropic fails startup rather than being ignored. Today it has one key.

llm:
  provider: openai
  apiKey: "${OPENAI_API_KEY}"
  model: gpt-5.1
  openai:
    reasoningSummary: auto
Key Meaning If omitted
reasoningSummary Whether to ask for a summary of the model's reasoning and stream it (auto | concise | detailed) Neither is asked for and nothing is streamed

On this vendor the reasoning itself is encrypted_content — ciphertext by design — so a summary is the only human-readable surrogate there is. That is why this key has a different name from Anthropic's thinkingDisplay.

Responses API only. If the model does not support the reasoning trace round trip, or that endpoint is switched off, the request goes to Chat Completions, which has no such parameter — and in that case the client says so once at WARN (rather than doing nothing in silence).

If cli.tracing is on, one more layer goes on top (line 697-712) — TracingLlmClient wraps the original client, and the same Tracer is injected into the executor factory as well, so turn/iteration/tool spans all gather in one tree. What gets wrapped is the agent turn path only. Background subsystems (wiki indexing, peer memory, dreamer) deliberately receive the unwrapped llmClient — those calls have no turn span context, so wrapping them would not produce spans anyway.

Your adaptation points: - If you have your own LLM gateway, implement LlmClient directly and inject it. You do not have to go through LlmClientFactory. - The LlmClient instance is application-scoped. Create it once and share it across every session. - If you are stacking a decorator, do as the CLI does and hold both the original and the wrapper, then decide which goes where. Merging them into one drags background work into your traces.

4.2 Output formatter + shell + skill parser (line 713-725)

final OutputFormatter outputFormatter = createOutputFormatter(config);
final LocalShell skillHookShell = new LocalShell();
final SkillParser skillParser = createShellAwareSkillParser(skillHookShell);
final AgentBundleLoader effectiveBundleLoader = (this.agentBundleLoader != null)
        ? this.agentBundleLoader
        : new AdaptiveAgentBundleLoader(DEFAULT_AGENT_BUNDLE_BASE_PATH,
                new MarkdownAgentDefinitionParser(),
                Thread.currentThread().getContextClassLoader(), skillParser);
final AgentBundle agentBundle = effectiveBundleLoader.load(extractAgentName(config));
  • OutputFormatter — owns console colouring and formatting. In your own application, replace it with an SSE streamer, a log appender, a WebSocket sender, ...
  • LocalShell — the shell that runs the shell action in a skill's frontmatter. It is AutoCloseable and is cleaned up in AgentSetup.close().
  • SkillParser — the markdown skill definition parser. Injecting LocalShell is what makes shell hooks actually run.
  • AgentBundleLoader — loads agents/<name>/agent.md together with the subagents and skills underneath it. It reads from the classpath, so it packages into a jar.

Your adaptation points: - If you want to build agent definitions dynamically from code or a database, build the AgentBundle yourself and inject it through AgentSetupFactory's package-private constructor. - To isolate shell execution itself in a container, implement VirtualShell yourselfLocalShell is the only built-in implementation, and the sandbox modules do not implement this SPI. What they isolate is four tools (RunSandbox and friends), not the shell.

4.3 Session record store, transcript manager, message queue, filesystem (line 726-733)

final SessionCheckpointMailbox sessionCheckpoints = createSessionCheckpointMailbox();
final InMemorySessionRecordStore sessionRecordStore = new InMemorySessionRecordStore();
final TranscriptManager transcriptManager = createTranscriptManager(sessionRecordStore, sessionCheckpoints);
final MessageQueueManager messageQueueManager = createMessageQueueManager();
final LocalFileSystem fileSystem = createFileSystem();

The default implementations (AgentSetupFactory.java:1033, 1044, 1076, 1083):

private TranscriptManager createTranscriptManager(InMemorySessionRecordStore repository,
        SessionCheckpointMailbox checkpoints) {
    return new DefaultTranscriptManager(repository, checkpoints);
}

private SessionCheckpointMailbox createSessionCheckpointMailbox() {
    return SessionCheckpointMailbox.background();
}

private MessageQueueManager createMessageQueueManager() {
    return new DefaultMessageQueueManager(new InMemoryMessageQueueRepository());
}

private LocalFileSystem createFileSystem() {
    final String workingDirectory = getJarDirectory();
    final LocalFileSystem fileSystem = new LocalFileSystem(new LocalFileSystemConfig(workingDirectory));
    fileSystem.initialize();
    return fileSystem;
}

Separating three names here makes everything that follows easier.

Type What it holds Lifetime
SessionRecordStore The persistent session record identified by SessionId — message history, SessionTotals, budgetOverride The store itself is application-scoped; its entries are per-session
TranscriptManager The manager that reads and writes the LLM message exchange (conversation) on top of that record Application-scoped
SessionCheckpointMailbox The mailbox that flushes an in-progress session asynchronously on its own thread, between end-of-turn saves Application-scoped

Hoisting sessionRecordStore into a factory-local variable is deliberate (see the comment at line 727-730). The same instance has to reach both the transcript manager (message history) and the LiveSession built below (the session's two persistent side fields — sessionTotals and budgetOverride). Split it into two and the transcript side stops seeing the totals the live session wrote back.

The governing principle (CLAUDE.md): a stateful component separates its store behind an interface. In-memory implementations are enough for the CLI, but in a multi-instance environment you swap SessionRecordStore / MessageQueueRepository for distributed-backend implementations.

Your adaptation points: - Multi-instance: InMemorySessionRecordStore → a persistent implementation from aimon-session-mongodb / aimon-session-postgres / aimon-session-redis. - Multi-user: LocalFileSystemGridFSFileSystem or S3FileSystem. Separate the working directory per user. - The producer (the REPL) and the consumer (the executor's ReAct loop) must share the same MessageQueueManager instance within one session — that is what makes mid-turn user input injection possible.

The Java names are Session* but the stored names are conversation_*. The Mongo collections (conversation_locks / conversation_inbox / conversation_signals), the Postgres tables and channels (conversation_*), the wire keys ("conversationId", "invokingConversationId") and the Redis key prefixes are deliberately frozen — the rename happened in Java identifiers only, so that already deployed data would not be forced through a migration. The boundary is the "Not changed (deliberately frozen)" list, ../migration/frozen-names.md. If your own store implementation uses those names, leaving them alone is the correct move.

4.4 Skill policy and the pending-turn registry (line 738-767)

This owns the skill invocation approval flow. In the CLI the user approves or rejects at an interactive prompt, but in your own application you can decide automatically by policy or delegate to an external approval system.

final PendingTurnRegistry pendingTurnRegistry = new InMemoryPendingTurnRegistry();
final PendingTurnReaper pendingTurnReaper = createPendingTurnReaper(pendingTurnRegistry, outputFormatter);
// Approvals split into two scopes — the default is per-session, and only an answer where the user
// explicitly said "always in this agent" goes to the agent-scoped store. The policy chain looks at
// the narrow one (the session) first.
final AgentApprovalStore agentApprovalStore = new InMemoryAgentApprovalStore();
final SessionApprovalStore sessionApprovalStore = new InMemorySessionApprovalStore();
// Materialise bundled (classpath) skills into the working VFS so that their attached files
// (scripts, references, templates) become real files the agent can read and ${AIMON_SKILL_DIR} resolves.
final SkillRegistry skillRegistry = OrcaAgentRuntimeFactory.buildMaterializedSkillRegistry(
        agentBundle, fileSystem, ".aimon/skills", ".aimon/bundled-skills",
        DEFAULT_AGENT_BUNDLE_BASE_PATH + "/" + extractAgentName(config) + "/skills",
        Thread.currentThread().getContextClassLoader(), skillParser);
final SkillInvocationPolicy skillInvocationPolicy =
        createSkillInvocationPolicy(sessionApprovalStore, agentApprovalStore);
final InteractiveSkillApprovalChannel skillApprovalChannel = new InteractiveSkillApprovalChannel(
        sessionApprovalStore, agentApprovalStore, outputFormatter);
final SkillPreflightScanner skillPreflightScanner = SkillPreflightScanner.builder()
        .policy(skillInvocationPolicy)
        .registry(skillRegistry)
        .approvalChannel(skillApprovalChannel)
        .build();

createSkillInvocationPolicy (AgentSetupFactory.java:998-1003) assembles the policy chain. The order is a contract:

return new SessionScopedSkillInvocationPolicy(sessionApprovalStore,          // 1. per-session (narrow)
        new ApprovalCachingSkillInvocationPolicy(agentApprovalStore,         // 2. agent-wide
                RuleBasedSkillInvocationPolicy.builder()                     // 3. rules
                        .defaultDecision(SkillInvocationDecision.ASK).build()));

The narrow scope going first is not a matter of taste; it is the only order that works. Reverse it and an agent-wide allow granted earlier answers first, so "deny in this session" can never be reached.

The name SessionApprovalStore was retired once and then reused with a different meaning. In old code and old documents this name referred to the agent-wide store keyed by AgentRuntimeId (the name was lying), and that store is now AgentApprovalStore (…skill.policy.agent). Today's SessionApprovalStore (…skill.policy.session) is the per-session store keyed by SessionId, the successor of the old ConversationApprovalStore. This is the easiest place in this file to get backwards, so consult the mapping table in scope-model.en.md §6. The meaning of an approval did not change at all — an agent-wide decision still has no TTL and is not cleared by /clear.

Adaptation points: - Headless environments (batch, a web API): auto-allow or auto-deny with, for example, RuleBasedSkillInvocationPolicy.builder().defaultDecision(SkillInvocationDecision.ALLOW). - External approval systems: implement SkillApprovalChannel yourself and send approval requests to Slack / email / a dashboard. - If you do not need the skills' attached files, the non-materialising buildSkillRegistry(...) overload is enough.

4.5 Creating the AgentExecutor (line 806-808)

final OrcaAgentExecutor agentExecutor = createAgentExecutor(
        effectiveLlmClient, transcriptManager, messageQueueManager,
        config.getCliSettings().isStreaming(),
        skillPreflightScanner, pendingTurnRegistry, memoryContextProvider);

OrcaAgentExecutorFactory builds the ReAct loop executor — the final call is create(llmClient, transcriptManager), and everything else is a with* setter in front of it. This instance is application-scoped too — every session shares it.

A few things are already stacked on the factory before create() (line 795-804): withRewakeService, withSubagentBehaviorRegistry, withCostEstimator, and, if tracing is on, withTracer / withTracePayloadPolicy. with* mutates the factory and returns itself, so building two executors from the same factory instance means the second inherits the first's configuration. If you plan to build several executors, build several factories.

4.6 Creating the SchedulingEngine (line 809, 862)

final SchedulingEngine schedulingEngine = createSchedulingEngine(agentRuntimeRegistry);
// ...
schedulingEngine.start();  // line 862 — after the runtime is registered
// AgentSetupFactory.java:1571
private static SchedulingEngine createSchedulingEngine(AgentRuntimeRegistry agentRuntimeRegistry) {
    return SchedulingEngineBuilder.create().agentRuntimeRegistry(agentRuntimeRegistry).build();
}

Lifecycle rule (scope-model.en.md §2): scheduling components are application-level (long-lived). The scheduling engine must survive the teardown of an AgentRuntime. The CLI closes it inside AgentSetup.close() because there the process lifetime is the session lifetime, but in an embedding you have to separate them.

The SchedulingEngineBuilder.agentRuntimeRegistry(...) parameter carries @ExternallyManaged — meaning "this is a borrowed reference and the engine does not close it". The annotation has no runtime behaviour and exists for documentation, but by convention it marks something that class must not close.

Adaptation points: - If you need distributed/clustered scheduling, swap in the Quartz-based implementation from aimon-scheduling-quartz. - AgentRuntimeRegistry must be created outside and injected — the engine does not own it.

4.7 Assembling the AgentRuntime (line 833-860)

final OrcaAgentRuntimeFactory agentRuntimeFactory =
    new OrcaAgentRuntimeFactory(
        "1.0.0",
        ".aimon/commands",
        ".aimon/agents",
        ".aimon/skills",
        createWikiKnowledgeStore(agentRuntimeRegistry, llmClient))
        .withSkillRegistry(skillRegistry)
        .withCodeSubagentRegistry(codeSubagentRegistry)
        .withPendingTurnRegistry(pendingTurnRegistry)
        .withAgentApprovalStore(agentApprovalStore)
        .withSessionApprovalStore(sessionApprovalStore)
        .withSkillInvocationPolicy(skillInvocationPolicy)
        .withToolContextEnrichers(toolContextEnrichers)
        .withRewakeService(rewakeService)
        .withWorkflowRunnerEnabled(enableWorkflow || enableWorkflowJs);

// AgentRuntimeId is not an argument — it is derived from the agent inside createAgentRuntime.
final OrcaAgentRuntime agentRuntime = createAgentRuntime(
    agentRuntimeFactory, agentExecutor,
    schedulingEngine.getTaskManager(), agentBundle, fileSystem,
    config, graalJsEngines);

configureHooks(agentRuntime, outputFormatter);
final HookHotReloadBootstrap.Started hookHotReload = setupHookHotReload(...);
registerCliTools(agentRuntime, outputFormatter, ...);
configureSchedulingEventListener(schedulingEngine, config);
agentRuntimeRegistry.register(agentRuntime);

The caller does not build an AgentRuntimeId and pass it in. createAgentRuntime derives it internally with AgentRuntimeId.from(agentBundle.getAgent()). Being deterministic is the point — the form is fixed as agent:<name> or agent:<name>:<discriminator>, so when cron re-fires long after the original session ended, ScheduledTask.boundRuntimeId still resolves to the same runtime. There is no such thing as generate() — had there been, that re-fire is exactly what would have broken. Use from(agent) / from(agent, discriminator) / fromName(name) / of(value) as needed.

OrcaAgentRuntime is an agent-scoped object — one per (Agent, discriminator), shared by every session of that agent. Do not build one per session. It holds:

  • The Agent definition + the system prompt
  • ToolRegistry (the built-in tools + the CLI tools)
  • SkillRegistry
  • HookRegistry
  • CommandRegistry, SubagentRegistry
  • VirtualFileSystem
  • McpClientManager (if MCP servers are configured)
  • KnowledgeStore (the wiki store)

The default tool providers come from OrcaAgentRuntimeFactory.defaultToolProviders()Read, Write, Edit, Bash, Grep, Glob, Todo, Subagent, Skill, Scheduling and so on.

Adaptation points: - Adding your own tool: agentRuntime.getToolRegistry().register(myTool) (see the registerCliTools pattern). - Adding your own hook: agentRuntime.getHookRegistry().register(HookEventType.PRE_TOOL, hook) (see the configureHooks pattern). There are no per-event register* methods; you register with a single type token. - Disabling some tools: pass a custom ToolProvider list to agentRuntimeFactory.create(...).

4.8 Creating the LiveSession (line 865-866)

final LiveSession liveSession = new DefaultLiveSession(
    sessionId,                       // SessionId.of("default") — line 773
    agentRuntime,
    agentExecutor,
    LiveSessionOptions.defaults(),
    messageQueueManager,
    null,                            // HookExecutionManager (OnSessionStart/End hooks) — unused by the CLI
    sessionRecordStore);             // where the session's persistent side fields live

DefaultLiveSession offers 4-, 5-, 6- and 7-arg constructors. The last three arguments are switches that turn on MessageQueueManager (mid-turn queueing), HookExecutionManager (session hooks) and SessionRecordStore (hydrating the persistent side fields) respectively; omit one and only that feature is off. Use the 4-arg form, for example, and offerAsync never queues — it always returns SubmitOutcome.Kind.EXECUTED.

LiveSession is the entry API you will meet most often from the outside. One submit(input) call runs one turn (which contains several ReAct iterations). For details see agent-session-guide.en.md.

A LiveSession is not a session; it is a handle on one. The persistent aggregate is the SessionRecord identified by SessionId, and LiveSession is the node-local, transient object that runs turns against that session. The relationship is 1 : 0..N — a session may have zero live handles (nobody is talking to it) or several serving it in sequence over time (idle-TTL eviction, process restart, handoff between nodes). That distinction is the premise of §6.

4.9 Bundling it into an AgentSetup and returning (line 880-889)

AgentSetup is the handle on every resource the CLI process created — not only the live session, but the agent scope and the application scope as well (a simplification the CLI can afford because process = one session). It is AutoCloseable, so closing it with try-with-resources tears things down in this order (AgentSetupFactory.java:320-428):

 1. memoryFinalDerivation.run()      // queue the final derivation while the transcript is still alive
 2. memoryQueue.stop()               // drain in-flight derivations (before the stores they depend on)
 3. dreamerSubsystem.close()
 4. memoryMaintenance.close()
 5. liveSession.close()              // handle resources only — does not close OrcaAgentRuntime
 6. sessionCheckpoints.close()       // after liveSession — the last end-of-turn save has drained
 7. agentRuntime.close()             // the app is exiting, so agent-scoped resources (MCP, ...) go here
 8. graalJsEngines.close()           // after runtime teardown — no script meets a half-closed engine
 9. agentRuntimeRegistry.unregister(agentRuntime.getId())
10. schedulingEngine.close()         // (CLI only — an embedding must separate this)
11. rewakeService.close()
12. pendingTurnReaper.close()
13. hookHotReload.close()            // before skillHookShell — reload callbacks use the shell
14. skillHookShell.close()

Four places in that order have a reason behind them, and every one is backed by a code comment: derivations → stores (2 before 3 and 4), live session → checkpoint mailbox (5 before 6), runtime → GraalJS engines (7 before 8), and hook hot reload → shell (13 before 14). When you reorder this in your own shell, preserve those four pairs.

The live session closes, but that is not why the runtime closes. liveSession.close() must not call OrcaAgentRuntime.close() — another session of the same agent may still be using that runtime (the MCP subprocesses, the KnowledgeStore). The point is that 5 and 7 are listed separately: the runtime actually closes because the CLI discards the agent as the process exits, not as a consequence of the session ending. In an embedding, runtime teardown is the job of OrcaAgentRuntimeManager.destroyRuntime, on application shutdown or on explicit agent removal.


5. Adaptation guide, component by component

Component What aimon-cli chose Common alternative in your application
LlmClient An OpenAI or Anthropic SDK wrapper Your own implementation wrapping an in-house LLM gateway
VirtualFileSystem LocalFileSystem (relative to the jar directory) GridFSFileSystem / S3FileSystem / a per-user isolated instance
VirtualShell LocalShell Implement it yourself — there is no container-isolated shell in the framework (the sandbox modules isolate tools instead)
SessionRecordStore InMemorySessionRecordStore A persistent implementation from aimon-session-mongodb / -postgres / -redis
TranscriptManager DefaultTranscriptManager (+ the background checkpoint mailbox) Usually unchanged — what you swap is the SessionRecordStore underneath
MessageQueueManager in-memory A distributed queue backend
AgentBundleLoader AdaptiveAgentBundleLoader (classpath) Inject an AgentBundle built from code or a database
SkillInvocationPolicy The interactive ASK policy A rule-based automatic policy or external approval
SchedulingEngine in-memory (the default) aimon-scheduling-quartz (distributed)
KnowledgeStore WikiKnowledgeStore (file-based) aimon-knowledge-opensearch (vector search)
HookRegistry ToolCallDisplayHook, SubagentResultDisplayHook Metrics / audit-log / request-response tracing hooks
Adding a Tool ConsoleOutputTool and friends Your own business tools (database lookups, in-house API calls, ...)
The interaction loop ReplSession (JLine) An HTTP handler / WebSocket / batch job

For writing tools follow tool-development-guide.en.md, for hooks hook-development-guide.en.md, and for LLM adapters llm-provider-development-guide.en.md.


6. Lifecycle and scopes

aimon-cli is one process = one session, so it builds everything at once and closes everything at once. An embedding has to separate the four scopes cleanly — copy the CLI verbatim and the agent scope collapses into the live-session scope.

The full normative rules are in scope-model.en.md. What follows is a summary mapped onto the CLI code.

Application scope (process lifetime)

Created once and shared by every agent and every session:

  • LlmClient
  • OrcaAgentExecutor
  • SchedulingEngine + ScheduledTaskManager, RoutineExecutor
  • AgentRuntimeRegistry
  • SessionRecordStore, SessionLeaseStore, TranscriptManager
  • AgentBundleLoader, AgentBundle (if the definition does not change)
  • The pool of MessageQueueManager instances

Agent scope ((Agent, discriminator) lifetime)

Created once per agent and shared by every session of that agent. Not closed when a session ends:

  • OrcaAgentRuntime
  • That runtime's McpClientManager and MCP clients (the subprocesses outlive a session)
  • KnowledgeStore (when it is split per agent)
  • The per-runtime ToolRegistry / HookRegistry
  • WorkflowRunner (the agent-scoped variant — when enabled with withWorkflowRunnerEnabled)

Create and look up with OrcaAgentRuntimeManager.getOrCreateRuntime(bundle, ...) — as the name says, an existing one is reused. Tear down only with destroyRuntime, on application shutdown or explicit agent removal.

OrcaAgentRuntime.close() does not scan for AgentScoped implementations — it closes a hardcoded list only (mcpClientManager, workflowRunner, ownedShell). If you add a new agent-scoped component holding a native resource (a connection pool, a watcher thread), you have to add it to that list yourself. The marker interface is documentation, not automatic teardown. ownedShell is the only conditional one of the three — it is null when an assembly handed in a shell with withShell(...), and closing that shell is then the giver's job.

Session scope (SessionId lifetime — persistent)

Kept for as long as the session exists, and surviving restarts, evictions and node moves:

  • SessionRecord (the message history)
  • SessionTotals, budgetOverride — the record's side fields
  • SessionTranscript

The point is that these values live on the record and not on the LiveSession. The live session writes the latter two back, one pair at a time, with SessionRecordStore.setTotalsAndBudgetOverride.

Live-session scope (the lifetime of one connection — node-local)

Created per handle, cleaned up with close():

  • LiveSession
  • The message-queue subscription and the event publisher
  • The turn-tracking state that handle created

LiveSession is a node-local, transient handle. One session (SessionId) may have zero live handles, or several over time (idle-TTL eviction, restart, node move). Values that must survive a restart belong on the SessionRecord, not on the handle.

When naming a new type: if it must persist, Session* (at.aimon.core.agent.session[.store|.transcript]); if it may die with the process, LiveSession* (at.aimon.core.agent.session); if it is gathered once per agent, Agent* (at.aimon.core.agent). The bare word Session and AgentSession are forbidden as type names and SessionNamingArchitectureTest blocks them at build time — those two names are precisely what make the two lifetimes impersonate each other. By contrast "conversation" is still a valid word and means the message exchange with the LLM (getConversationHistory(), /compact's "Conversation compacted"). Do not use it to mean a lifetime.

Do not infer a lifetime from the last noun in a name. *Store / *Registry / *Manager / *Factory is a container that manages X, and the container's own lifetime is not X's lifetime — SessionRecordStore has per-session entries but an application-scoped instance, and so does AgentRuntimeRegistry. Judge by what it is keyed by, not by the name: Map<AgentRuntimeId, _> is agent-scoped, Map<SessionId, _> is session-scoped.

Wrong patterns

// Wrong (1): closing the SchedulingEngine from a live session's close()
//            kills every other session's scheduled tasks
try (AgentSetup setup = factory.create(config)) {
    // ...
}
// → setup.close() calls schedulingEngine.close() (the CLI assumption)

// Wrong (2): building a runtime per session and closing it in the session's close()
OrcaAgentRuntime rt = factory.create(...);   // restarts the MCP subprocesses every session
liveSession.close();
rt.close();   // cuts off the MCP/KnowledgeStore another session of the same agent was using

// Wrong (3): holding the session totals inside the live session
//            they vanish silently when the handle is evicted or the node changes. Put them on the SessionRecord.

The embedding pattern

// Once, at application startup
SchedulingEngine engine = SchedulingEngineBuilder.create()
    .agentRuntimeRegistry(registry).build();
engine.start();
LlmClient llmClient = new OpenAILlmClient(openAiConfig);
OrcaAgentExecutor executor = ...;
SessionRecordStore sessionRecords = ...;   // app-scoped. Persistent if it must survive a restart

// Once per agent (reused if it already exists)
OrcaAgentRuntime runtime = runtimeManager.getOrCreateRuntime(agentBundle, ...);

// Per connection — the runtime is neither built nor closed here
LiveSession session = new DefaultLiveSession(
    SessionId.of(userId), runtime, executor, LiveSessionOptions.defaults(),
    queueManager, null, sessionRecords);
try {
    AgentExecutionResult result = session.submit(input);   // synchronous — returns when the turn ends
} finally {
    session.close();              // handle resources only; the runtime stays alive
}

// Once, at application shutdown
runtimeManager.destroyRuntime(runtime.getId());   // only now are MCP/KnowledgeStore released
engine.close();

For the embedding patterns in detail see embedding-agent-in-application.en.md.


7. Minimal embedding example

aimon-cli's bootstrap compressed into its simplest possible form. Minimal code that actually runs.

import at.aimon.cli.config.CliConfig;
import at.aimon.cli.config.CliConfigLoader;
import at.aimon.cli.factory.AgentSetupFactory;
import at.aimon.cli.factory.AgentSetupFactory.AgentSetup;
import at.aimon.core.agent.AgentExecutionResult;
import at.aimon.core.agent.session.LiveSession;

public class MyEmbeddedAgent {
    public static void main(String[] args) throws Exception {
        // 1. Load the configuration (your own YAML, or built in code)
        CliConfig config = new CliConfigLoader().loadDefault();

        // 2. Create the AgentSetup exactly once (application scope)
        try (AgentSetup setup = new AgentSetupFactory().create(config)) {

            // 3. Submit input through the live session.
            //    submit(...) is synchronous — it blocks until the turn ends and returns the result.
            LiveSession session = setup.getLiveSession();
            AgentExecutionResult result =
                session.submit("Read the README.md and summarize it");

            // 4. Use the result
            if (result.isSuccess()) {
                System.out.println(result.getFinalAnswer());
            } else {
                System.err.println(result.getErrorMessage());
            }
        }
    }
}

If you want to run asynchronously while receiving events, submitAsync(input, listener) returns a CompletionStage<AgentExecutionResult> — that is the counterpart to the synchronous submit above.

CompletionStage<AgentExecutionResult> stage = session.submitAsync(
    "Read the README.md and summarize it",
    event -> System.out.println(event));   // token deltas, tool calls, iteration progress ...
AgentExecutionResult result = stage.toCompletableFuture().get();

For a more aggressive embedding (swapping in your own components), move the body of AgentSetupFactory's create() into your own composition root and inject your implementations stage by stage. Follow the 4. AgentSetupFactory.create() line by line section above as-is.


8. Moving to a web application

aimon-cli's AgentSetupFactory.create() builds the application, agent and session scopes as one lump — possible only because of the single-user / single-process assumption. On the web you have to split them apart so that app-scoped beans are built once, agent-scoped runtimes once per agent, and only the live session per user connection. This section shows that split in four steps.

  1. Component scope separation table
  2. The Spring Boot composition root
  3. LiveSession ↔ HTTP/SSE adapter
  4. Non-interactive skill approval channel

This section shows the hand-assembled path. If a multi-node deployment also needs session routing, leases and handoff, aimon-session-routing's SessionRouter (SessionRouter.builder()) already implements that layer — for the operational configuration see web-session-deployment-guide.en.md.

8.1 Component scope separation table

Component Scope Kind of bean Notes
LlmClient app @Bean(destroyMethod = "close") Holds the SDK connection pool. Shared by every user
OrcaAgentExecutor app @Bean singleton Stateless. Shared by every session
SchedulingEngine app @Bean(initMethod = "start", destroyMethod = "close") Never tie it to a session's close()
AgentRuntimeRegistry app @Bean singleton Used by SchedulingEngine for lazy lookup
AgentBundleLoader, AgentBundle app @Bean singleton Load once if the definition is static
PendingTurnReaper app @Bean(initMethod = "start", destroyMethod = "close") One daemon thread is enough
LocalShell (for skill hooks) app @Bean(destroyMethod = "close") Shares an I/O thread pool
SessionRecordStore app @Bean singleton Entries are per-session, the instance is app-scoped. A persistent Mongo/Postgres/Redis implementation is mandatory across multiple instances
TranscriptManager app @Bean singleton The default implementation wrapping the store above
PendingTurnRegistry, AgentApprovalStore, SessionApprovalStore app @Bean singleton Entries are keyed by pending turn / AgentRuntimeId / SessionId respectively, but the instances are app-scoped. Use distributed backends if routing is not guaranteed in a cluster
OrcaAgentRuntime agent OrcaAgentRuntimeManager.getOrCreateRuntime() One per (Agent, discriminator). Owns MCP and the KnowledgeStore. Do not close it from a live session's close()
OrcaAgentRuntimeManager app @Bean singleton Owns the creation, caching and teardown of agent-scoped runtimes
MessageQueueManager live session Created fresh by a factory each time The producer (HTTP) and the consumer (the executor) must share the same instance
LiveSession live session Created fresh by a factory each time 0..1 at a time per SessionId, N over time
VirtualFileSystem user/agent Per-user separation recommended Separate GridFS buckets / S3 prefixes

The four most common mistakes: - Creating OrcaAgentExecutor/LlmClient per connection — expensive and pointless. - Making MessageQueueManager an app-scoped singleton — another user's mid-turn input leaks in. - Making OrcaAgentRuntime live-session-scoped — MCP subprocesses restart on every connection, and when one handle closes it cuts off the MCP and KnowledgeStore another session of the same agent was using. If you need to split VirtualFileSystem per user, pass the user as a discriminator and use getOrCreateRuntime(bundle, userId, fs, store) — one per user, not one per connection. - Holding values that must survive a restart inside LiveSession — accumulated tokens/cost and the budget override belong on the record in SessionRecordStore. Kept inside the handle, they vanish silently on the first idle-TTL eviction.

8.2 The Spring Boot composition root

App scope — @Configuration

@Configuration
public class AimonAppConfig {

    @Bean(destroyMethod = "close")
    public LlmClient llmClient(@Value("${aimon.openai.key}") String apiKey,
                               @Value("${aimon.openai.model:gpt-5.1}") String model) {
        return new OpenAILlmClient(OpenAIConfig.builder()
            .apiKey(apiKey)
            .model(model)
            .timeout(Duration.ofSeconds(60))
            .build());
    }

    @Bean
    public AgentRuntimeRegistry agentRuntimeRegistry() {
        return new DefaultAgentRuntimeRegistry();
    }

    @Bean(initMethod = "start", destroyMethod = "close")
    public SchedulingEngine schedulingEngine(AgentRuntimeRegistry registry) {
        return SchedulingEngineBuilder.create().agentRuntimeRegistry(registry).build();
    }

    @Bean
    public SessionRecordStore sessionRecordStore() {
        // In a multi-instance environment, swap for an aimon-session-mongodb / -postgres / -redis
        // implementation. The instance is app-scoped; only the entries split by SessionId.
        return new InMemorySessionRecordStore();
    }

    @Bean(destroyMethod = "close")
    public SessionCheckpointMailbox sessionCheckpoints() {
        // Flushes asynchronously on its own thread so appended messages survive a mid-turn crash.
        return SessionCheckpointMailbox.background();
    }

    @Bean
    public TranscriptManager transcriptManager(SessionRecordStore store,
                                               SessionCheckpointMailbox checkpoints) {
        return new DefaultTranscriptManager(store, checkpoints);
    }

    @Bean
    public OrcaAgentExecutor agentExecutor(LlmClient llmClient,
                                           TranscriptManager transcriptManager) {
        return new OrcaAgentExecutorFactory()
            .withUseStreaming(true)
            .create(llmClient, transcriptManager);
    }

    @Bean(destroyMethod = "close")
    public VirtualShell skillHookShell() {
        return new LocalShell();
    }

    @Bean
    public SkillParser skillParser(VirtualShell skillHookShell) {
        return new MarkdownSkillParser(
            new ShellArgumentTokenizer(),
            new SkillHookSetParser(new DefaultShellActionExecutor(skillHookShell)));
    }

    @Bean
    public AgentBundle defaultAgentBundle(SkillParser skillParser) {
        return new AdaptiveAgentBundleLoader(
            "agents", new MarkdownAgentDefinitionParser(),
            getClass().getClassLoader(), skillParser).load("default");
    }

    @Bean
    public PendingTurnRegistry pendingTurnRegistry() {
        return new InMemoryPendingTurnRegistry();
    }

    @Bean(initMethod = "start", destroyMethod = "close")
    public PendingTurnReaper pendingTurnReaper(PendingTurnRegistry registry) {
        return PendingTurnReaper.builder()
            .registry(registry)
            .interval(Duration.ofSeconds(60))
            .expirationListener(turns -> { /* metrics/logging */ })
            .build();
    }

    @Bean
    public SessionApprovalStore sessionApprovalStore() {
        // at.aimon.core.skill.policy.session — the narrow one, keyed by SessionId
        return new InMemorySessionApprovalStore();
    }

    @Bean
    public AgentApprovalStore agentApprovalStore() {
        // at.aimon.core.skill.policy.agent — the wide one, keyed by AgentRuntimeId
        return new InMemoryAgentApprovalStore();
    }

    @Bean
    public SkillInvocationPolicy skillInvocationPolicy(
            SessionApprovalStore sessionApprovals, AgentApprovalStore agentApprovals) {
        // See 8.4 — an automatic policy, or ASK + suspend/resume.
        // Narrow first: session approvals → agent-wide approvals → rules. Reverse this order
        // and a per-session denial can never be reached.
        return new SessionScopedSkillInvocationPolicy(sessionApprovals,
            new ApprovalCachingSkillInvocationPolicy(agentApprovals,
                RuleBasedSkillInvocationPolicy.builder()
                    .defaultDecision(SkillInvocationDecision.ASK).build()));
    }
}

Agent-scoped runtime — once per user (not once per request)

OrcaAgentRuntimeManager is an app-scoped singleton bean. getOrCreateRuntime() does exactly what the name says — a cache lookup first, creation only on a miss — and registers with the registry internally, so the caller does not need a separate register() call. If you need per-user VFS separation, pass the userId as a discriminator instead of creating a new runtime per connection.

@Bean
public OrcaAgentRuntimeManager agentRuntimeManager(
        OrcaAgentExecutor executor, AgentRuntimeRegistry registry,
        SchedulingEngine schedulingEngine,
        SkillInvocationPolicy skillPolicy, SessionApprovalStore sessionApprovals,
        AgentApprovalStore agentApprovals, PendingTurnRegistry pendingTurnRegistry) {

    // withSkillRegistry() is deliberately not called — the VFS differs per user, so the skill
    // registry has to differ per runtime too. Omit it and the factory builds a fresh one per
    // runtime from (agentBundle, fileSystem).
    OrcaAgentRuntimeFactory runtimeFactory =
        new OrcaAgentRuntimeFactory("1.0.0",
            ".aimon/commands", ".aimon/agents", ".aimon/skills",
            /* knowledgeStore */ null)
            .withSessionApprovalStore(sessionApprovals)
            .withAgentApprovalStore(agentApprovals)
            .withPendingTurnRegistry(pendingTurnRegistry)
            .withSkillInvocationPolicy(skillPolicy);

    return OrcaAgentRuntimeManager.builder()
        .agentExecutor(executor)
        .agentRuntimeRegistry(registry)
        .agentRuntimeFactory(runtimeFactory)
        .scheduledTaskManager(schedulingEngine.getTaskManager())
        .toolProviders(OrcaAgentRuntimeFactory.defaultToolProviders())
        .commandProviders(OrcaAgentRuntimeFactory.defaultCommandProviders())
        .build();
}

The live-session factory — a new handle per connection (the runtime is reused)

@Component
public class WebLiveSessionOpener {

    private final OrcaAgentExecutor executor;
    private final OrcaAgentRuntimeManager runtimeManager;
    private final AgentBundle agentBundle;
    private final CredentialStore credentialStore;
    private final SessionRecordStore sessionRecords;   // app-scoped — only injected
    private final VirtualFileSystemProvider fsProvider;

    // constructor injection omitted

    public LiveSession openFor(String userId, SessionId sessionId) {
        VirtualFileSystem userFs = fsProvider.forUser(userId);  // per-user GridFS bucket / S3 prefix

        // Agent scope: actually created only once per userId. From the second session on, the cached
        // instance comes back, so the MCP subprocesses are not restarted either.
        OrcaAgentRuntime runtime =
            runtimeManager.getOrCreateRuntime(agentBundle, userId, userFs, credentialStore);

        // Live-session scope: new per handle. The producer (HTTP) and the consumer (the executor)
        // have to see the same instance.
        MessageQueueManager queueManager = new DefaultMessageQueueManager(
            new InMemoryMessageQueueRepository());

        // The last argument is the SessionRecordStore. Pass it and the session totals (SessionTotals)
        // and budgetOverride are restored on open and written back at the end of every turn. Omit it
        // (null) and those values disappear the moment this handle closes — they have to survive
        // restarts and evictions, so on the web you always pass it.
        return new DefaultLiveSession(sessionId, runtime, executor,
            LiveSessionOptions.defaults(), queueManager, /* hookExecutionManager */ null, sessionRecords);
    }

    public void close(LiveSession session) {
        session.close();   // cleans up the live-session scope only
        // Do not unregister/close the runtime — another session of the same user is still using it.
        // Never close the SchedulingEngine either — that is @PreDestroy's job.
    }
}

Call runtimeManager.destroyRuntime(AgentRuntimeId.from(agent, userId)) only when the user logs out or when you reclaim idle users, and only after every live session of that user has closed. The reason you can recompute the id as AgentRuntimeId.from(agent, userId) and pass it in is that issuing it is deterministic — there is no generate().

8.3 LiveSession ↔ HTTP/SSE adapter

A web client has to receive several events per message — token deltas, tool calls, iteration progress, completion. LiveSession.submitAsync(input, listener) supports exactly that (LiveSession.java:162).

The live-session store

@Component
public class LiveSessionRegistry {

    private final Map<SessionId, LiveSession> live = new ConcurrentHashMap<>();
    private final WebLiveSessionOpener opener;

    public LiveSession getOrOpen(String userId, SessionId sessionId) {
        return live.computeIfAbsent(sessionId, id -> opener.openFor(userId, id));
    }

    public Optional<LiveSession> peek(SessionId sessionId) {
        return Optional.ofNullable(live.get(sessionId));
    }

    public void close(SessionId sessionId) {
        LiveSession session = live.remove(sessionId);
        if (session != null) {
            opener.close(session);   // only the handle closes. The runtime and the session record live on
        }
    }
}

In production, add policies such as TTL eviction (30 minutes idle, say), a maximum number of handles per user, and a bulk close on instance shutdown. Binding opener.close() to a Caffeine/Guava cache removalListener is a common pattern. This eviction policy applies to live sessions only — when a handle disappears on TTL the agent-scoped runtime stays, so the same user reconnecting resumes immediately with no MCP restart. If you need to reclaim runtimes, hang destroyRuntime off a separate (and much longer) idle policy.

The point of this whole subsection is that this map is keyed by SessionId while its values have a shorter lifetime. For one SessionId there are 0..1 live handles at a time and N over time — every eviction, reconnect and process restart hands the same session to a new handle. So values that must survive that handover, such as accumulated tokens and cost or the budget override, belong on the record in SessionRecordStore rather than in this map (the very store WebLiveSessionOpener passes as its last argument in 8.2).

The SSE controller (Spring WebMVC)

@RestController
@RequestMapping("/agent/sessions/{sessionId}")
public class AgentChatController {

    private final LiveSessionRegistry sessions;

    @PostMapping(value = "/messages", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter sendMessage(@PathVariable String sessionId,
                                  @AuthenticationPrincipal Principal user,
                                  @RequestBody MessageRequest body) {

        SseEmitter emitter = new SseEmitter(Duration.ofMinutes(5).toMillis());
        LiveSession session = sessions.getOrOpen(user.getName(), SessionId.of(sessionId));

        // offerAsync stacks input into the mid-turn queue if the session is already running a turn
        SubmitOutcome outcome = session.offerAsync(body.text(), event -> {
            try {
                emitter.send(SseEmitter.event()
                    .name(event.getClass().getSimpleName())
                    .data(EventDto.from(event)));   // serialise with your own DTO
            } catch (IOException ignored) {
                // if the client disconnects the next event fails again — clean up then
            }
        });

        if (outcome.getKind() == SubmitOutcome.Kind.QUEUED) {
            try {
                emitter.send(SseEmitter.event().name("queued")
                    .data("Session busy, queued at position " + outcome.getQueuePosition()));
            } catch (IOException ignored) {}
        }

        // Send the id of the turn that just started down to the client — /interrupt below gets it back.
        // Only when EXECUTED: if it was QUEUED the turn currently running is someone else's, and telling
        // the user it is this response's turn means their "stop" click kills an innocent turn.
        // This is a best-effort value too (see §Concurrency notes), so if it is empty, just do not send it.
        if (outcome.getKind() == SubmitOutcome.Kind.EXECUTED) {
            session.currentTurnId().ifPresent(turnId -> {
                try {
                    emitter.send(SseEmitter.event().name("turn").data(turnId.value()));
                } catch (IOException ignored) {}
            });
        }

        // getResultStage() is an Optional — a QUEUED outcome has no stage to attach yet.
        // The result of queued input arrives over the event stream when the session actually starts that turn.
        outcome.getResultStage().ifPresent(stage -> stage.whenComplete((result, ex) -> {
            try {
                if (ex != null) {
                    emitter.completeWithError(ex);
                } else {
                    emitter.send(SseEmitter.event().name("done")
                        .data(ResultDto.from(result)));
                    emitter.complete();
                }
            } catch (IOException e) {
                emitter.completeWithError(e);
            }
        }));

        return emitter;
    }

    @PostMapping("/interrupt")
    public ResponseEntity<Void> interrupt(@PathVariable String sessionId,
                                          @RequestBody InterruptRequest body) {
        // Use the addressed form. The no-arg interrupt(reason) cuts "whatever turn is running right now",
        // which fits administrative purposes (eviction, shutdown, lease loss) but is wrong for
        // "stop the turn I sent" — that turn may have finished and the next one started before the
        // user's click arrived. Pass a turnId and a mismatch becomes a quiet no-op instead of
        // killing an innocent turn.
        sessions.peek(SessionId.of(sessionId)).ifPresent(session ->
            session.interrupt(TurnId.of(body.turnId()), InterruptReason.USER_SIGINT));
        return ResponseEntity.accepted().build();
    }

    @DeleteMapping
    public ResponseEntity<Void> close(@PathVariable String sessionId) {
        sessions.close(SessionId.of(sessionId));   // closes the handle only — the session record remains
        return ResponseEntity.noContent().build();
    }
}

InterruptReason is an enum — there is no factory taking a free-form reason string. A web "stop" button should use USER_SIGINT ("SIGINT on the CLI host, or equivalent"), and for administrative purposes there are separate SESSION_RELEASED / SYSTEM_SHUTDOWN / LEASE_LOST / HOLDER_LOST. This call cannot tell you whether the interrupt actually landed (that is inherently racy) — observe the turn's completion event instead.

Concurrency notes

  • LiveSession offers no thread-safety guarantee (LiveSession.java:42-47). One handle must run one turn at a time.
  • When concurrent requests arrive for the same SessionId, use offerAsync rather than submitAsync so they stack into the mid-turn queue. Check SubmitOutcome.getKind() for immediate execution (EXECUTED) versus waiting (QUEUED).
  • status() and currentTurnId() are not control gates. Both are best-effort observations read without synchronisation, so they can be briefly out of step with a turn that is settling. "May I start a turn" is answered only by the SubmitOutcome that offerAsync returns.
  • An interrupt is not a synchronous call — for one turn only the first trip means anything (later calls are idempotent no-ops), and the actual stop lands at the next ReAct iteration or when the tool finishes. With no active turn it is a quiet no-op and throws nothing.
  • Across multiple instances there are two roads:
  • Session-affinity routing: a SessionId → instance mapping (sticky sessions, gateway routing rules). If you also need leases and handoff, do not write it yourself — use SessionRouter.
  • Making the handle stateless: rebuild LiveSession on every request and push all state into SessionRecordStore. Be clear about the trade-off, though: in-memory state such as mid-turn interrupts and queueing disappears.

8.4 Non-interactive skill approval channel

SkillApprovalChannel is a synchronous interface (the "Stay synchronous" contract in SkillApprovalChannel.java):

"Stay synchronous. The scanner blocks on this call. Implementations that genuinely need async resolution should not implement this interface; they should let the suspend/resume path run instead."

In a web environment this synchronous contract forks the road in two.

Option A — rule-based automatic decisions (automated workflows)

If your own policy can decide immediately, the synchronous channel is clean.

public class PolicyBasedApprovalChannel implements SkillApprovalChannel {

    private final SessionApprovalStore sessionApprovals;   // the narrow one — keyed by SessionId
    private final AgentApprovalStore agentApprovals;       // the wide one — keyed by AgentRuntimeId
    private final SkillPolicyEvaluator evaluator;

    // The 2-arg form is the interface's abstract method. An implementation that knows about sessions
    // overrides the 3-arg one and lets the 2-arg one delegate as "a call with no session".
    @Override
    public void requestApproval(List<PendingSkillRequest> pendingRequests,
                                AgentRuntimeId agentRuntimeId) {
        requestApproval(pendingRequests, agentRuntimeId, null);
    }

    @Override
    public void requestApproval(List<PendingSkillRequest> pendingRequests,
                                AgentRuntimeId agentRuntimeId, SessionId sessionId) {
        for (PendingSkillRequest req : pendingRequests) {
            // Never throw — on failure record the safe default (DENY) (SkillApprovalChannel's "Never throw" contract)
            SkillInvocationDecision decision;
            try {
                decision = evaluator.evaluate(req.getSkillName(), req.getArgs());
            } catch (Exception e) {
                decision = SkillInvocationDecision.DENY;
            }
            // The scanner does not read the channel's return value. You must write into a store the
            // policy chain reads, and a skill you did not write becomes a plain ASK again at the next check.
            if (sessionId != null) {
                sessionApprovals.put(sessionId, req.getSkillName(), decision);
            } else {
                // Calls with a null sessionId really do happen — executions that are not a user-driven
                // turn, such as scheduled tasks. Do not silently drop them; record on the wide side at least.
                agentApprovals.put(agentRuntimeId, req.getSkillName(), decision);
            }
        }
    }
}

IMPORTANT — an approval put into AgentApprovalStore never expires: the key is AgentRuntimeId (agent:<name>[:<discriminator>]), so a decision recorded here applies to every later session of that agent, has no TTL, and is not cleared by /clear. Use this store only when the user has explicitly answered "always in this agent" — the user cannot see the other sessions their answer will reach, so an ordinary "yes" must not be promoted into this scope. For "allow in this session only", use the per-session SessionApprovalStore (at.aimon.core.skill.policy.session); wrap it in SessionScopedSkillInvocationPolicy and the policy chain looks there first (see the chain in 8.2). The way back is each store's invalidate(...), which the CLI exposes as /revoke (session) and /revoke --agent (agent-wide) — if you build a web UI, you must offer the equivalent cancel buttons too.

The reach of a per-session approval is that session and the executions that session delegated (subagent forks, skill forks, foreground workflows). It is easy to misread how it reaches them, though — a fork does not have its own SessionId. DefaultSubagentExecutor does not put SESSION_ID into the tool context at all; it exposes the execution identity ExecutionId as EXECUTION_ID, and the id of the user session that launched it as INVOKING_SESSION_ID. The policy finds its answer through the latter. When a fork launches another fork, the user's session id is passed straight through rather than the intermediate fork's. A fork has no channel to ask a human — nor should the channel be reachable from a fork, since the user is not looking at that screen — so without this path every skill call from a fork is blocked.

The two ids run on different axes: sessionId is lifetime (what my session is) and invokingSessionId is reach (whose decision applies to me). And the wire keys are still "conversationId" / "invokingConversationId" — only the Java identifiers were renamed; the serialised names are deliberately frozen for compatibility. The stored names looking out of step with the type names is normal.

Take special care here, because the name was reused: SessionApprovalStore used to be the name of the agent-wide store (keyed by AgentRuntimeId while the name said session). That is now AgentApprovalStore, and the vacated name was reattached to the real per-session store. If you see SessionApprovalStore in old code or old documents, it may be today's AgentApprovalStore — tell them apart by package (…policy.agent vs …policy.session) and key type. The old ConversationApprovalStore is today's SessionApprovalStore, and the old ConversationAwareSkillInvocationPolicy is today's SessionScopedSkillInvocationPolicy.

When the rules are simple, you often need no channel at all and RuleBasedSkillInvocationPolicy finishes the job — if the policy returns ALLOW/DENY directly instead of ASK, the channel is never called. Rules are glob patterns on skill names, not arbitrary lambdas, and they evaluate in the order deny → allow → safe-by-default → defaultDecision.

SkillInvocationPolicy autoPolicy = RuleBasedSkillInvocationPolicy.builder()
    .addDenyPattern("dangerous-*")          // highest priority
    .addAllowPattern("report-*")
    .safeByDefault(false)                   // defaults to true — turn it off and ALLOW comes only from explicit allow patterns
    .defaultDecision(SkillInvocationDecision.DENY)  // when no rule matched. The default is DENY too (fail-closed)
    .build();

defaultDecision defaults to DENY — the CLI uses ASK because an interactive shell has someone to ask, not because that is the framework default. Leave it at ASK in an unattended workflow and, absent a channel, it falls into option B's suspend path and the turn stops.

Option B — external approval (when a human has to decide)

For flows that need a human click, do not build a synchronous channel; use the scanner's fallback, the suspend/resume path. It is the same mechanism as aimon-cli's /approve, /deny and /pending commands — only the input channel changes from the terminal to HTTP.

The flow:

  1. The scanner finds no channel (or falls back to DENY) → the turn is registered in PendingTurnRegistry and suspended
  2. The client receives the pending event from the events() stream
  3. The user approves or rejects in a separate UI
  4. The decision is sent to a backend API
  5. The controller records the decision in the store matching the scope (SessionApprovalStore or AgentApprovalStore), then removes the pending entry with pendingTurnRegistry.remove(turnId)
  6. The client resubmits the same prompt → the scanner asks the policy again, this time gets the cached ALLOW/DENY → the turn proceeds

A caution about steps 5–6: there is no API such as resume(turnId). PendingTurnRegistry is a pure store and does not resume execution — recording the approval and removing the entry is the server's share, and the actual re-run happens because the client submits the turn again (the CLI's /approve likewise only says "Resume the agent to continue"; it does not resume by itself).

@RestController
@RequestMapping("/agent/pending/{turnId}")
public class PendingApprovalController {

    private final PendingTurnRegistry pendingTurns;
    private final SessionApprovalStore sessionApprovals;
    private final AgentApprovalStore agentApprovals;

    @PostMapping("/decide")
    public ResponseEntity<Void> decide(@PathVariable String turnId,
                                       @RequestBody ApprovalRequest body) {
        PendingTurn pending = pendingTurns.get(PendingTurnId.of(turnId))
            .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));

        // A pending turn knows which session's turn it was — but it is an Optional.
        // It is empty when the turn was not user-driven (a scheduled task, say).
        Optional<SessionId> sessionId = pending.getSessionId();

        for (PendingSkillRequest req : pending.getPendingSkills()) {
            SkillInvocationDecision decision = body.allows(req.getSkillName())
                ? SkillInvocationDecision.ALLOW
                : SkillInvocationDecision.DENY;

            if (sessionId.isPresent() && !body.forWholeAgent()) {
                // The default path. Applies only to this session and the executions it delegated.
                sessionApprovals.put(sessionId.get(), req.getSkillName(), decision);
            } else {
                // Careful: this decision lands on all of pending.getAgentRuntimeId(), not this session,
                // and never expires. Come down this branch only when the user explicitly answered
                // "always in this agent".
                agentApprovals.put(pending.getAgentRuntimeId(), req.getSkillName(), decision);
            }
        }
        pendingTurns.remove(PendingTurnId.of(turnId));
        return ResponseEntity.accepted().build();
    }
}

It is a good idea to open an endpoint for revoking approvals as well — the equivalent of the CLI's /revoke. Open it for both scopes. Open only the narrow one and the UI has no way back from an agent-wide approval the user clicked by mistake.

@DeleteMapping("/agent/sessions/{sessionId}/approvals")
public ResponseEntity<Void> revokeSession(@PathVariable String sessionId) {
    sessionApprovals.invalidate(SessionId.of(sessionId));      // /revoke
    return ResponseEntity.noContent().build();
}

@DeleteMapping("/agent/{agentRuntimeId}/approvals")
public ResponseEntity<Void> revokeAgent(@PathVariable String agentRuntimeId) {
    agentApprovals.invalidate(AgentRuntimeId.of(agentRuntimeId));   // /revoke --agent
    return ResponseEntity.noContent().build();
}

A TTL caution: PendingTurnReaper sweeps up pending turns periodically (AgentSetupFactory.createPendingTurnReaper, a 60-second sweep by default). If the client leaves the approval UI up too long, the turn expires and the user's decision is ignored — the UX has to show a countdown or an automatic rejection. The sweep interval decides how quickly expired entries are collected, not whether something has expired — the actual expiry time is PendingTurn.getExpiresAt().


9. Other adaptation scenarios

General patterns, CLI or web alike.

9.1 Registering your own tool

public class CompanyDirectoryTool extends AbstractTool {
    public static final String TOOL_NAME = "CompanyDirectory";
    private final DirectoryService directory;

    public CompanyDirectoryTool(DirectoryService directory) {
        super(TOOL_NAME,
              "Look up an employee by email or employee ID.",
              createInputSchema());
        this.directory = Objects.requireNonNull(directory);
    }
    // ... the execute implementation
}

// registration
agentRuntime.getToolRegistry().register(new CompanyDirectoryTool(svc));

Exactly the pattern in AgentSetupFactory.registerCliTools(). There is one ToolRegistry per runtime, so registration happens once, when the runtime is created — register per connection and the same tool is registered repeatedly. On the web the canonical route is to hand it to OrcaAgentRuntimeManager.builder().toolProviders(...) as an OrcaToolProvider, and the manager then registers it exactly once for every runtime it creates. (Hooks go in at the same place, via hookRegistrars(...).)

9.2 Audit-log hook

// There is no per-event-type register* method — you register with a single type token.
hookRegistry.register(HookEventType.PRE_TOOL, (PreToolHook) ctx -> {
    auditLog.info("invoker={} tool={} input={} attrs={}",
        ctx.getInvokerName(), ctx.getCurrentToolUse().getName(),
        ctx.getCurrentToolUse().getInput(), ctx.getExecutionAttributes());
    return HookResult.allow();
});

For the kinds of hook and what blocking means, follow hook-development-guide.en.md. Only PreToolHook has a meaningful block (HookResult.block(reason)) — every other hook is non-blocking.

If you want the user's identity in the audit log, note that you cannot pull it out of the hook context — there is no getUserId() on PreToolContext. Load it at submit time with SubmitOptions.builder().executionAttribute("userId", ...) and it arrives intact in getExecutionAttributes(). Hook registration happens against the agent-scoped HookRegistry, so this too is once per runtime rather than once per connection — user identity has to ride along at submit time, not at registration time.


10. Checklist

What to check when integrating aimon-core into a new host application.

Dependencies

  • Did you add aimon-core as implementation()?
  • Did you add at least one LLM implementation module?
  • Did you pick and add the filesystem / scheduling / knowledge modules you need?

Composition

  • Are LlmClient, OrcaAgentExecutor and SchedulingEngine application-scoped?
  • Did you create AgentRuntimeRegistry outside and inject it into SchedulingEngine?
  • Is OrcaAgentRuntime agent-scoped and obtained only through OrcaAgentRuntimeManager.getOrCreateRuntime()? (You are not creating one per session?)
  • Do you derive AgentRuntimeId with from(agent) / from(agent, discriminator)? (generate() does not exist)
  • Do you create a new LiveSession per connection but open it with the same SessionId so it picks up the previous session?
  • Did you pass SessionRecordStore to LiveSession so totals and the budget override outlive the handle?
  • Is MessageQueueManager a single instance within the same live session?
  • (web only) You are not creating LlmClient/OrcaAgentExecutor per user request?
  • (web only) You are not making MessageQueueManager an app-scoped singleton?

Lifecycle

  • On live-session teardown do you call only liveSession.close(), and neither close nor unregister the AgentRuntime?
  • Do you avoid closing SchedulingEngine on live-session teardown? (the difference from the CLI)
  • Do you tear down AgentRuntime only via destroyRuntime() at app shutdown or on explicit agent removal?
  • Does your application shutdown hook close SchedulingEngine, LlmClient and any shared VirtualFileSystem?
  • If you added a new agent-scoped component, did you add it by hand to the hardcoded list in OrcaAgentRuntime.close()? (the AgentScoped marker is documentation only — there is no fan-out)
  • (web only) Does your live-session store have idle-TTL eviction, and does eviction close the handle only? (not the runtime as well?)

Tools / hooks / skills

  • Do your own tools honour the AbstractTool contract (throw nothing, return ToolResult.error())?
  • Do you avoid attempting to block from anything but PreToolHook?
  • In a headless environment, does the skill approval policy decide automatically or delegate to an external approval system?
  • When recording an approval decision, is the narrow scope (SessionApprovalStore) the default, with agent-wide used only when the user explicitly answered that way?
  • Is the policy chain arranged narrow first (session → agent → rules)? Reverse the order and a per-session denial can never be reached
  • Did you open the revoke path for both scopes? (the equivalents of /revoke and /revoke --agent)
  • (web only) For skills that need a human decision, do you use suspend/resume plus an HTTP decision endpoint rather than a synchronous channel?

Concurrency / HTTP

  • (web only) Do you queue concurrent requests for the same SessionId with offerAsync, or reject them explicitly?
  • (web only) Do you judge with SubmitOutcome rather than using status() / currentTurnId() as a control gate?
  • (web only) Does your SSE/WebSocket stream detect client disconnects (IOException) and clean up?
  • (web only) Do you send user-initiated interrupts addressed to a turn with interrupt(turnId, reason)? (the no-arg form is for administrative purposes)
  • (web only) Have you surfaced to the client that interrupt() does not guarantee immediate termination?

Multiple instances (optional)

  • Did you replace the in-memory implementations (InMemorySessionRecordStore, InMemoryMessageQueueRepository, InMemoryPendingTurnRegistry, InMemoryAgentApprovalStore, InMemorySessionApprovalStore) with distributed backends?
  • Does SchedulingEngine use a clusterable implementation (aimon-scheduling-quartz)?
  • (web only) Do you use session-affinity routing (SessionRouter), or rebuild LiveSession statelessly on every request?

References