ai-agents
Audience
PLANA staff. The runtime behind every BOS agent chat. For the customer view, see BOS → Chat and agents.
ai-agents is the FastAPI service that runs the four BOS agents (Finance, Warehouse, Marketing, Sales). It accepts a chat message, the agent decides which tools to call against the customer's tenant Odoo, and the response streams back over Server-Sent Events.
Stack
| Property | Value |
|---|---|
| Repo | git.planapulse.com/plana-pulse/ai-agents |
| Language | Python 3.12 |
| Framework | FastAPI |
| LLM SDK | Anthropic Python SDK |
| Embeddings | sentence-transformers (768 dims) for the Neural Business Network |
| Image base | python:3.12-slim |
| Namespace | ai-bos-agent |
| Port | 8000 |
| Tenant-side RPC | Odoo JSON-RPC (via in-cluster Service URL) |
Agents and tools
Each agent has a registry-driven set of tools — small functions that read from (or, with caution, write to) the tenant Odoo:
Finance agent (finance)
| Tool | Reads from | Returns |
|---|---|---|
GetGLBalance | account_move_line | GL account balances |
ListGLAccounts | account_account | Account hierarchy |
GetCashFlow | account_move_line (cash accounts) | Cash position by date |
GetBankBalance | account_journal + account_move_line | Per-journal balance |
GetBankTransactions | account_bank_statement_line | Imported transactions |
ReconcileAccount | (read-only currently) | Suggested reconciliations |
Warehouse agent (warehouse, BOS ID operations)
| Tool | Reads from | Returns |
|---|---|---|
GetStockLevels | stock.quant | On-hand by product / location |
GetReorderAlerts | stock.warehouse.orderpoint + stock.quant | Products below reorder point |
GetSlowMovingStock | stock.move | SKUs with no movement in N days |
GetPendingReceipts | stock.picking | Receipts not yet validated |
Marketing agent (marketing)
| Tool | Reads from | Returns |
|---|---|---|
GetPipelineOverview | crm.lead | Pipeline by stage |
GetLeadScoring | crm.lead | Lead scores |
GetCampaignMetrics | mailing.mailing | Campaign send / open rates |
GetConversionRates | crm.lead + sale.order | Stage conversion |
Sales agent (sales)
| Tool | Reads from | Returns |
|---|---|---|
GetPipelineValue | crm.lead | Pipeline value by stage |
GetDealVelocity | crm.lead | Time-in-stage analytics |
GetTopCustomers | sale.order | Top N customers by revenue |
GetUpsellOpportunities | sale.order + res.partner | Suggested upsells |
Architecture
Customer chats in BOS
│
▼
pulse-account-api ─── proxies to ───→ ai-agents
│
│ 1. Load agent registry by id
│ 2. Build system prompt + tool schemas
│ 3. Stream completion from Anthropic
│
├──→ Anthropic API (claude-sonnet-4-6)
│
│ 4. On tool_call event, dispatch:
│
├──→ Tenant Odoo via JSON-RPC
│
│ 5. Send tool_result back to model
│ 6. Continue completion
│
│ 7. Emit text_delta events
▼
SSE stream back to BOSThe control loop is in BaseAgent.run_stream():
async for event in claude.stream_complete(prompt, tools):
if event.type == "tool_call":
result = await self._execute_tool_call(event)
yield {"type": "tool_result", "data": result}
elif event.type == "text_delta":
yield {"type": "text_delta", "data": event.text}
elif event.type == "done":
yield {"type": "done"}API surface
POST /agents/{agent_id}/chat non-streaming
POST /agents/{agent_id}/chat/stream SSE streaming
GET /agents/{agent_id}/executions recent tool-call audit
GET /healthz liveness
GET /readyz readiness (Anthropic API reachable)Authentication: X-API-Key header. The key is checked against per-caller records — pulse-account-api has one, pulse-portal another for internal use.
Tool execution
When the model emits a tool call, the agent dispatches it via the tool's implementation. Each tool:
- Authenticates against the tenant Odoo via JSON-RPC (
ODOO_URLenv var, but per-tenant URL via the workspace binding) - Builds an ORM query (
search_readtypically) - Returns a JSON-serialisable result
- Logs the execution to
PLANA:executions:{workspace}Redis log with:- Tool name
- Arguments (after sensitive-field redaction)
- Result size
- Latency in ms
The Execution log is what customers see in BOS → Execution log.
Shadow Mode
On by default. Every agent runs with shadow_mode=True, which means:
- Read-only tools execute normally
- Write tools (currently none in production) emit a
would_executeevent in the log instead of mutating data - The model sees the "what would have happened" result and continues the conversation
Shadow Mode protects against any unintentional write coming out of LLM reasoning. The workspace owner can disable it per-agent from Settings (when write tools ship, which is not yet the case).
Streaming
Streaming is end-to-end SSE:
| Layer | Streams |
|---|---|
Anthropic SDK → BaseLLMClient.stream_complete() | text_delta events from the model |
BaseAgent.run_stream() | wraps text_delta + emits tool_call / tool_result events |
ai-agents FastAPI route | SSE response with text/event-stream |
pulse-account-api proxy | reply.hijack() to forward bytes verbatim |
bos-portal (Vue 3 SPA) | EventSource client; updates chat bubble per delta |
Total round-trip from first byte to first text_delta is typically 800–1500ms for claude-sonnet-4-6.
Guardrails
- Per-call timeout — 60 seconds for a full chat turn; tool calls individually timeout at 30 seconds
- Per-tool rate limit — bursty workloads cap at 30 RPS per tool
- Anomaly detection — sudden 10× growth in tool calls from one workspace flags an alert
- Kill switch —
ai-agentscan be disabled globally viafeature_flag.agents_enabled=false; existing chats return a "agents temporarily unavailable" message
Configuration
| Env var | Purpose |
|---|---|
ANTHROPIC_API_KEY | Per-org key; SOPS ai_agents.anthropic_api_key |
ANTHROPIC_MODEL | Default: claude-sonnet-4-6; overrides for batch jobs |
OPENROUTER_API_KEY | Fallback router; SOPS |
ODOO_URL | Currently a global URL; per-tenant binding via workspace lookup is in flight |
REDIS_URL | For execution log |
Multi-tenancy
Per tenant, the agent:
- Resolves the tenant's Odoo URL from the workspace binding (
pulse-account-apiprovides this at request time) - Uses the tenant's API user credentials (looked up in SOPS per-tenant today; a per-tenant credentials service is on the roadmap)
- Logs to
PLANA:sessions:{tenant_id}:{session_id}for chat memory - Logs to
PLANA:executions:{workspace}for the audit feed
A bug in one tenant's tool call does not affect another tenant — each request is independent.
Operational tasks
See an agent's recent failures
kubectl -n ai-bos-agent logs deploy/ai-agents --tail=200 | grep ERRORTest a tool in isolation
kubectl -n ai-bos-agent exec deploy/ai-agents -it -- python -c "
from agents.finance.tools import GetCashFlow
import asyncio
print(asyncio.run(GetCashFlow().run(
odoo_url='https://e2etest.planapulse.app',
workspace='e2etest', date_from='2026-05-01'
)))
"Switch the default model
For a quality vs cost tradeoff:
kubectl -n ai-bos-agent set env deploy/ai-agents \
ANTHROPIC_MODEL=claude-haiku-4-5-20251001Best for low-stakes workloads; Sonnet is the right default for routine chats; Opus for complex reasoning on accounting questions.
Where to read more
- BOS → How agents work — customer view
- BOS → Safety and limits — Shadow Mode + guardrails for end-users
- pulse-account-api — proxy layer
- pulse-events — the bus where agents emit reports
- Source:
infra/k8s/ai-bos-agent/