aimon-core integration guide — following aimon-cli as the reference¶
Walks through how
aimon-cliassemblesaimon-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¶
- The big picture
- Module dependencies and build setup
- Bootstrap flow —
AimonCli.call() AgentSetupFactory.create()line by line- Adaptation guide, component by component
- Lifecycle and scopes
- Minimal embedding example
- Moving to a web application
- Other adaptation scenarios
- 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 withapi()— 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 on —
Environment 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: offstill meansoff—offis a YAML 1.1 boolean, and only the written text tells it apart fromnoandfalse.
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)¶
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:
providerapiKey— that vendor's keybaseUrl— remove it. The shipped file sets OpenAI's host, and a leftover one keeps every request on that hostmodel— 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 Anthropicagent.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 (none…high) — 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 againstmax_tokens, so the client clamps tomax_tokens - 1and says so at WARN. Thatmax_tokensis the agent definition'smodel.maxTokens, andAnthropicConfig's default of 4096 when it sets none — so on such an agentthinkingBudgetTokens: 8000goes out as 4095. Every agent definition bundled with the CLI setsmaxTokens: 40000, so on those the budget goes out as 8000. There is no key here for raising that ceiling — it is the agent definition'smodel.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.
| 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 theshellaction in a skill's frontmatter. It isAutoCloseableand is cleaned up inAgentSetup.close().SkillParser— the markdown skill definition parser. InjectingLocalShellis what makesshellhooks actually run.AgentBundleLoader— loadsagents/<name>/agent.mdtogether 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 yourself — LocalShell 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: LocalFileSystem → GridFSFileSystem 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 areconversation_*. 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
SessionApprovalStorewas retired once and then reused with a different meaning. In old code and old documents this name referred to the agent-wide store keyed byAgentRuntimeId(the name was lying), and that store is nowAgentApprovalStore(…skill.policy.agent). Today'sSessionApprovalStore(…skill.policy.session) is the per-session store keyed bySessionId, the successor of the oldConversationApprovalStore. 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 insideAgentSetup.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
Agentdefinition + the system prompt ToolRegistry(the built-in tools + the CLI tools)SkillRegistryHookRegistryCommandRegistry,SubagentRegistryVirtualFileSystemMcpClientManager(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
LiveSessionis not a session; it is a handle on one. The persistent aggregate is theSessionRecordidentified bySessionId, andLiveSessionis 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 callOrcaAgentRuntime.close()— another session of the same agent may still be using that runtime (the MCP subprocesses, theKnowledgeStore). 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 ofOrcaAgentRuntimeManager.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:
LlmClientOrcaAgentExecutorSchedulingEngine+ScheduledTaskManager,RoutineExecutorAgentRuntimeRegistrySessionRecordStore,SessionLeaseStore,TranscriptManagerAgentBundleLoader,AgentBundle(if the definition does not change)- The pool of
MessageQueueManagerinstances
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
McpClientManagerand 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 withwithWorkflowRunnerEnabled)
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 forAgentScopedimplementations — 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.ownedShellis the only conditional one of the three — it is null when an assembly handed in a shell withwithShell(...), 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 fieldsSessionTranscript
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 wordSessionandAgentSessionare forbidden as type names andSessionNamingArchitectureTestblocks 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/*Factoryis a container that manages X, and the container's own lifetime is not X's lifetime —SessionRecordStorehas per-session entries but an application-scoped instance, and so doesAgentRuntimeRegistry. 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.
- Component scope separation table
- The Spring Boot composition root
LiveSession↔ HTTP/SSE adapter- 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'sSessionRouter(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/LlmClientper connection — expensive and pointless. - MakingMessageQueueManageran app-scoped singleton — another user's mid-turn input leaks in. - MakingOrcaAgentRuntimelive-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 splitVirtualFileSystemper user, pass the user as a discriminator and usegetOrCreateRuntime(bundle, userId, fs, store)— one per user, not one per connection. - Holding values that must survive a restart insideLiveSession— accumulated tokens/cost and the budget override belong on the record inSessionRecordStore. 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 asAgentRuntimeId.from(agent, userId)and pass it in is that issuing it is deterministic — there is nogenerate().
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, hangdestroyRuntimeoff a separate (and much longer) idle policy.The point of this whole subsection is that this map is keyed by
SessionIdwhile its values have a shorter lifetime. For oneSessionIdthere 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 inSessionRecordStorerather than in this map (the very storeWebLiveSessionOpenerpasses 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();
}
}
InterruptReasonis an enum — there is no factory taking a free-form reason string. A web "stop" button should useUSER_SIGINT("SIGINT on the CLI host, or equivalent"), and for administrative purposes there are separateSESSION_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¶
LiveSessionoffers 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, useofferAsyncrather thansubmitAsyncso they stack into the mid-turn queue. CheckSubmitOutcome.getKind()for immediate execution (EXECUTED) versus waiting (QUEUED). status()andcurrentTurnId()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 theSubmitOutcomethatofferAsyncreturns.- 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 — useSessionRouter. - Making the handle stateless: rebuild
LiveSessionon every request and push all state intoSessionRecordStore. 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
AgentApprovalStorenever expires: the key isAgentRuntimeId(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-sessionSessionApprovalStore(at.aimon.core.skill.policy.session); wrap it inSessionScopedSkillInvocationPolicyand the policy chain looks there first (see the chain in 8.2). The way back is each store'sinvalidate(...), 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.DefaultSubagentExecutordoes not putSESSION_IDinto the tool context at all; it exposes the execution identityExecutionIdasEXECUTION_ID, and the id of the user session that launched it asINVOKING_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:
sessionIdis lifetime (what my session is) andinvokingSessionIdis 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:
SessionApprovalStoreused to be the name of the agent-wide store (keyed byAgentRuntimeIdwhile the name said session). That is nowAgentApprovalStore, and the vacated name was reattached to the real per-session store. If you seeSessionApprovalStorein old code or old documents, it may be today'sAgentApprovalStore— tell them apart by package (…policy.agentvs…policy.session) and key type. The oldConversationApprovalStoreis today'sSessionApprovalStore, and the oldConversationAwareSkillInvocationPolicyis today'sSessionScopedSkillInvocationPolicy.
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();
defaultDecisiondefaults toDENY— the CLI usesASKbecause an interactive shell has someone to ask, not because that is the framework default. Leave it atASKin 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:
- The scanner finds no channel (or falls back to DENY) → the turn is registered in
PendingTurnRegistryand suspended - The client receives the pending event from the
events()stream - The user approves or rejects in a separate UI
- The decision is sent to a backend API
- The controller records the decision in the store matching the scope (
SessionApprovalStoreorAgentApprovalStore), then removes the pending entry withpendingTurnRegistry.remove(turnId) - 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).PendingTurnRegistryis 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/approvelikewise 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:
PendingTurnReapersweeps 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 isPendingTurn.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()onPreToolContext. Load it at submit time withSubmitOptions.builder().executionAttribute("userId", ...)and it arrives intact ingetExecutionAttributes(). Hook registration happens against the agent-scopedHookRegistry, 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-coreasimplementation()? - Did you add at least one LLM implementation module?
- Did you pick and add the filesystem / scheduling / knowledge modules you need?
Composition¶
- Are
LlmClient,OrcaAgentExecutorandSchedulingEngineapplication-scoped? - Did you create
AgentRuntimeRegistryoutside and inject it intoSchedulingEngine? - Is
OrcaAgentRuntimeagent-scoped and obtained only throughOrcaAgentRuntimeManager.getOrCreateRuntime()? (You are not creating one per session?) - Do you derive
AgentRuntimeIdwithfrom(agent)/from(agent, discriminator)? (generate()does not exist) - Do you create a new
LiveSessionper connection but open it with the sameSessionIdso it picks up the previous session? - Did you pass
SessionRecordStoretoLiveSessionso totals and the budget override outlive the handle? - Is
MessageQueueManagera single instance within the same live session? - (web only) You are not creating
LlmClient/OrcaAgentExecutorper user request? - (web only) You are not making
MessageQueueManageran app-scoped singleton?
Lifecycle¶
- On live-session teardown do you call only
liveSession.close(), and neither close nor unregister theAgentRuntime? - Do you avoid closing
SchedulingEngineon live-session teardown? (the difference from the CLI) - Do you tear down
AgentRuntimeonly viadestroyRuntime()at app shutdown or on explicit agent removal? - Does your application shutdown hook close
SchedulingEngine,LlmClientand any sharedVirtualFileSystem? - If you added a new agent-scoped component, did you add it by hand to the hardcoded list in
OrcaAgentRuntime.close()? (theAgentScopedmarker 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
AbstractToolcontract (throw nothing, returnToolResult.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
/revokeand/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
SessionIdwithofferAsync, or reject them explicitly? - (web only) Do you judge with
SubmitOutcomerather than usingstatus()/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
SchedulingEngineuse a clusterable implementation (aimon-scheduling-quartz)? - (web only) Do you use session-affinity routing (
SessionRouter), or rebuildLiveSessionstatelessly on every request?
References¶
- Core abstraction reference: architecture.en.md
- The reference document for lifetime, ownership and teardown: scope-model.en.md — read it before you create a new type or call
close() - Glossary: glossary.en.md
- SDK embedding patterns (scopes, multi-session, streaming): embedding-agent-in-application.en.md
- The
LiveSessionAPI and event streaming: agent-session-guide.en.md - Multi-node session routing and lease operations: web-session-deployment-guide.en.md
- Tool development: tool-development-guide.en.md
- Hook development: hook-development-guide.en.md
- LLM provider development: llm-provider-development-guide.en.md
aimon-clientry point:modules/aimon-cli/src/main/java/at/aimon/cli/AimonCli.javaaimon-clicomposition root:modules/aimon-cli/src/main/java/at/aimon/cli/factory/AgentSetupFactory.javaaimon-cliLLM factory:modules/aimon-cli/src/main/java/at/aimon/cli/factory/LlmClientFactory.java- Default configuration:
modules/aimon-cli/src/main/resources/default-config.yaml