Agent Pipeline
Technical architecture for the inbound email agent pipeline — step modules, the walker, security scanning, threading, and human review.
Agent Pipeline Architecture
The agent pipeline is the technical heart of Owlat's evolution from an outbound email platform to a communication intelligence system. Every inbound email flows through a multi-step processing chain that scans, retrieves context, classifies, drafts, and routes to either auto-send or a human review queue.
The core pipeline is built and running: the step modules and walker (apps/api/convex/agent/), the inbound receiver (apps/api/convex/inbox/messages.ts), the full schema (apps/api/convex/schema/inbox.ts), and the inbox UI (apps/web/app/pages/dashboard/inbox/*) all ship.
Most of what this doc once flagged as aspirational is now wired: the LLM prompt-injection classifier (pattern + best-effort guard-tier LLM), message coalescing (opt-in debounce), graduated-autonomy routing with a live feedback loop and three circuit breakers, and the knowledge graph + relevant source files feeding the agent's context briefing. What remains future is multi-intent branching / parallel workers — the walker still runs a single linear chain. Each surface is labelled inline. See Knowledge Graph for the knowledge side and AI Agent & Autonomy for the operator-facing guide.
Step-module architecture
The pipeline is not one big function. Per ADR-0014, each stage is an Agent step module — a small object with two pure functions — and a single walker drives them in sequence. The step modules live under apps/api/convex/agent/steps/<kind>/index.ts; the walker is apps/api/convex/agent/walker.ts.
A step module (apps/api/convex/agent/steps/types.ts) is:
interface AgentStepModule<K, In, Out> {
readonly kind: K
readonly llm?: { tier: 'fast' | 'capable' } // present iff the step calls an LLM
execute(ctx, input): Promise<AgentStepResult<Out>> // does the work
route(output, input, runCtx): AgentRoute // decides what happens next
}
route() returns one of three shapes:
| Route | Meaning |
|---|---|
in_state | Stay in the current processingStatus, schedule the next step. |
transition | Move processingStatus to a new state (optionally schedule a follow-up step). |
done | Stop — the pipeline is finished for this message. |
The walker (runStep) opens an agentActions row, calls module.execute(), applies module.route(), and self-schedules the next step via ctx.scheduler.runAfter(0, …). Any exception inside a step transitions the message to failed with the failing action recorded.
The walker runs a single linear step chain per message. Classification produces one Classification object, and routing is in_state / transition / done — there is no parallel "Worker A / Worker B" fork. Multi-intent branching (below) remains a future design.
The five steps
The step kinds are exactly: security_scan, context_retrieval, classify, draft, route (AgentStepKind in agent/steps/types.ts). A plan step was designed but dropped pre-prod with ADR-0014 — there is no action-planning stage.
Step 1: Security scan
apps/api/convex/agent/steps/security_scan/index.ts — the entry step, run while processingStatus is received/security_check. It scans the inbound message for prompt injection, instruction smuggling, and spam. The deterministic layer uses detectInjection, detectSmuggling, and calculateSpamScore from patterns.ts; on top of that, a guard-tier LLM injection classifier (getLLMProvider('guard') + runLlmObject) catches novel/obfuscated phrasings the regexes miss. The two verdicts are OR-ed — either flagging with high confidence is treated as an injection — and the LLM call fails open (any model error falls back to the pattern-only verdict so a flaky model never blocks the pipeline).
Routing:
- Injection detected (pattern or guard LLM), confidence ≥ 0.7 →
quarantined(held for human release). - Spam score ≥ 80 →
archived(reasonspam). - Clean, agent enabled → transition to
classifyingand schedulecontext_retrieval. - Clean, agent disabled → record the scan and stop (
done).
The guard model tier (apps/api/convex/lib/llmProvider.ts) is now wired: alongside the regex scan, security_scan/index.ts runs a runLlmObject classifier that returns { isInjection, confidence, reason }. A high-confidence LLM verdict (≥ 0.8) flags injection even when no pattern matched (injectionType: llm_prompt_injection). Detection is no longer fully deterministic — it's patterns + a best-effort LLM that fails open on any model error.
Step 2: Context retrieval
apps/api/convex/agent/steps/context_retrieval/index.ts — runs in_state while processingStatus is classifying. It assembles a briefing string the LLM steps consume from:
- Contact profile — name, language, timezone from
contacts - Recent activity — the last few
contactActivities - Conversation history — earlier messages in the same thread (
inboundMessagesby thread) - Knowledge graph — semantically relevant typed entries via
internal.knowledge.retrieval.semanticSearch(the[KNOWLEDGE]section, capped byknowledgeEntryLimit) - Relevant source files — semantically relevant uploaded documents via
internal.semanticFileProcessing.semanticSearch(the[RELEVANT FILES]section, capped byfileLimit) - The current message — from / to / subject / body
The briefing is token-budgeted (≈4 chars/token, 4000-token budget) with three compaction tiers, recorded on inboundMessages.contextTier:
| Tier | Trigger | Approach |
|---|---|---|
normal | Fits within budget | Pass all context verbatim. |
compacted | Up to 3× budget | Keep the tail of the assembled context (most recent material). |
emergency | Over 3× budget | Keep only the contact line and the current message. |
Context retrieval now queries the knowledge graph and the file index. When the query text is long enough, it runs vector semantic search over knowledgeEntries (internal.knowledge.retrieval.semanticSearch) and over uploaded files (internal.semanticFileProcessing.semanticSearch), folding the hits into the [KNOWLEDGE] and [RELEVANT FILES] briefing sections behind the same token budget. Both knowledgeEntryLimit and fileLimit are now live. See Knowledge Graph for the retrieval side.
Step 3: Classification
apps/api/convex/agent/steps/classify/index.ts — uses the fast model tier via getLLMProvider('classify') and runLlmObject for type-safe structured output (generateObject under the hood). It produces one classification:
{
category: 'support' | 'sales' | 'billing' | 'feature_request'
| 'complaint' | 'spam' | 'internal' | 'other',
priority: 'urgent' | 'normal' | 'low',
sentiment: 'positive' | 'neutral' | 'negative',
intent: 'question' | 'complaint' | 'request' | 'information'
| 'escalation' | 'acknowledgment',
confidence: number, // 0–1
}
Routing: spam → archived; complaint or urgent → straight to draft_ready (skip the drafter, force human review); everything else → transition to drafting and schedule the draft step. The classification is stored on inboundMessages.classification.
Step 4: Draft generation
apps/api/convex/agent/steps/draft/index.ts — uses the capable model tier via getLLMProvider('draft') and runLlmText. The draft is grounded in agentConfig.toneDescription, agentConfig.signatureTemplate, and the assembled context. As defense-in-depth, the drafter re-scans the assembled context for injection patterns (thread history that wasn't individually scanned upstream); a high-confidence hit fails the step. Output is stored to inboundMessages.draftResponse / draftSubject with a confidenceScore.
Step 5: Routing
apps/api/convex/agent/steps/route/index.ts — the terminal step. It decides auto-approve vs. human review in three tiers, safest-first:
- Circuit-breaker safety gate. Any open breaker (
internal.agentHealth.getCircuitBreakersInternal) forceshuman_review— a degraded pipeline never auto-sends, regardless of config. - Graduated autonomy (when the
ai.autonomyflag is on).internal.autonomy.checkPermissionInternalapplies the per-categoryautonomyRules(threshold + daily cap + breaker check). A category with no rule is never auto-approved; a permitted one charges the per-category daily count. - Legacy global fallback (autonomy flag off). The single
agentConfigsingleton —isAutoReplyEnabled,confidenceThreshold, and the daily-cap fields.
On auto_approve it transitions to approved; the lifecycle's schedule_send_approved effect then sends the reply via internal.agent.agentPipeline.sendApprovedReply. Otherwise it transitions to draft_ready (awaiting human review).
The route step no longer consults only agentConfig. It now reads the circuit breakers and, behind the ai.autonomy flag, the per-category autonomyRules via internal.autonomy.checkPermissionInternal; the legacy global threshold is just the fallback tier. See Graduated autonomy below.
Inbound email integration
MTA → Convex flow
Inbound mail for the shared agent inbox arrives over the standard MTA webhook, not a dedicated inbound route:
Inbound SMTP → owlat-mta
→ POST /webhooks/mta (apps/api/convex/http.ts → handleMtaWebhook)
→ webhook pipeline verifies HMAC, parses the `inbound.received` event
(apps/api/convex/webhooks/adapters/mta.ts)
→ dispatcher routes inbound.received → internal.inbox.messages.receiveMessage
POST /webhooks/mta carries delivery events and the agent-inbox inbound.received event. The separate POST /webhooks/mta-mailbox route is for Postbox (per-user personal mail) and goes through mail/webhook.ts, not the agent pipeline.
receiveMessage (apps/api/convex/inbox/messages.ts, an internalMutation) then:
- Upserts the sender as a
contactsrow (channelemail, sourceinbound). - Resolves or creates a
conversationThreadsrow via the threading cascade. - Inserts the
inboundMessagesrow withprocessingStatus: 'received'. - Records an
inbound_receivedcontact activity. - Schedules
internal.agent.walker.start, which kicks the pipeline off atsecurity_scan.
Threading
Threading (owned by the thread module called from receiveMessage) is a three-strategy cascade:
In-Reply-Toheader → find the referenced message's thread.Referencesheader → find any referenced message's thread.- Fallback: normalized subject (Re:/Fwd: stripped, lowercased) +
contactIdentifier.
A matched thread reopens if it was closed; thread messageCount / lastMessageAt are maintained atomically.
Schema additions
The inbox/agent tables are defined in apps/api/convex/schema/inbox.ts.
inboundMessages
Stores every inbound email with its processing state.
| Field | Type | Description |
|---|---|---|
messageId | string | SMTP Message-ID |
from, to, subject | string | Envelope data |
textBody, htmlBody | string? | Message content |
inReplyTo, references | string? | Threading headers |
headers, attachmentMeta | string? | Raw headers / attachment metadata (JSON) |
threadId | id → conversationThreads | Conversation thread |
contactId | id → contacts | Linked contact |
processingStatus | enum | received, security_check, quarantined, classifying, drafting, draft_ready, approved, sent, rejected, archived, failed |
securityFlags | object? | Pattern-scan results (injection, spam score, confidence) |
classification | object? | Classifier result (category, priority, sentiment, intent, confidence) |
draftResponse, draftSubject | string? | Agent-generated draft |
confidenceScore | number? | Overall confidence for the routing decision |
contextTier | enum? | normal / compacted / emergency |
assignedTo | string? | Human reviewer (BetterAuth user ID) |
errorMessage | string? | Pipeline error detail |
receivedAt | number | Timestamp the message was received |
processedAt | number? | Timestamp the pipeline finished (set once processing completes) |
conversationThreads
Groups related messages into conversations.
| Field | Type | Description |
|---|---|---|
subject, normalizedSubject | string | Display subject / matching key |
contactId | id → contacts | Linked contact |
contactIdentifier | string | Channel-neutral display identifier — email for email/generic, phone/handle for SMS/WhatsApp/chat (renamed from contactEmail in ADR-0032) |
status | enum | open, waiting, resolved, closed |
assignedTo | string? | Assigned team member |
latestDraftStatus | enum? | pending / approved / rejected / sent — for fast queue filtering |
messageCount, firstMessageAt, lastMessageAt, createdAt | number | Thread metadata |
agentActions
One row per pipeline step execution (not per planned action).
| Field | Type | Description |
|---|---|---|
inboundMessageId | id → inboundMessages | Source message |
actionType | enum | security_scan, context_retrieval, classify, draft, route (the step kinds) |
status | enum | pending, running, completed, failed, skipped |
input, output | string? | Step input / output (JSON) |
errorMessage, retryCount | string? / number | Error tracking |
startedAt, completedAt, durationMs | number? | Timing |
modelUsed, tokenUsage | string? / object? | LLM usage (for classify / draft) |
agentConfig
The operational-tuning singleton. The master on/off switch is the ai.agent feature flag (isAgentEnabled in agent/agentPipeline.ts calls isFeatureEnabled(ctx, 'ai.agent')), not a column on this table.
| Field | Type | Description |
|---|---|---|
isAutoReplyEnabled | boolean | Allow auto-sending without human review |
confidenceThreshold | number (0–1) | Minimum confidence for auto-approval |
toneDescription | string? | Organization communication style |
signatureTemplate | string? | Email signature for agent drafts |
maxDailyAutoReplies | number? | Daily auto-reply cap |
dailyAutoReplyCount, dailyAutoReplyResetAt | number? | Rolling daily-cap counters |
coalesceWindowMs | number? | Coalescing debounce window. Opt-in: a positive value enables coalescing; unset/0 (the default) processes each message immediately |
Model routing
Different pipeline steps have different requirements. Classification needs speed and structured output; draft generation needs quality and nuance. The provider resolver (apps/api/convex/lib/llmProvider.ts) routes each task to one of two model tiers.
Per-task model selection
getLLMProvider(task) resolves an OpenAI-compatible client and the model id for the task's tier:
// apps/api/convex/lib/llmProvider.ts
export function getLLMProvider(task: LLMTask = 'draft') {
return getClient()(modelIdForTier(taskTier(task)))
}
Tier resolution lives in taskTier() (apps/api/convex/lib/llmProvider.ts):
| Task | Tier | Used by |
|---|---|---|
classify | fast | Classification step |
extract | fast | Knowledge extraction |
guard | fast | Security-scan LLM injection classifier |
summarize | fast | Summarization |
draft | capable | Draft generation step |
plan | capable | (reserved — the plan step was dropped) |
Configuration
See ADR-007 for the provider abstraction and ADR-009 for the routing decision. The defaults are gpt-4o-mini (fast) and gpt-4o (capable), per apps/api/convex/lib/llmProvider.ts.
| Variable | Description | Default |
|---|---|---|
LLM_MODEL | Fallback model for all tasks | — |
LLM_MODEL_CAPABLE | Model for the draft (capable) tier | Falls back to LLM_MODEL, then gpt-4o |
LLM_MODEL_FAST | Model for fast-tier tasks (classify, extract, guard, summarize) | Falls back to LLM_MODEL, then gpt-4o-mini |
Self-hosters running a single Ollama model can set only LLM_MODEL — both tiers fall back to it. Organizations with GPU budget can split: a small model for classification and a larger model for drafting.
Quarantine
Messages flagged by the security scan are not silently dropped. They are stored with processingStatus: 'quarantined' and a securityFlags object, and surface in a dedicated admin view (apps/web/app/pages/dashboard/inbox/quarantine.vue) where a human can review and release them. Releasing a quarantined message transitions it back to received (the lifecycle's release_quarantine source), which re-runs the pipeline from security_scan.
Verification queue
The verification queue is the human-in-the-loop interface — a view over inboundMessages filtered by processingStatus = 'draft_ready', not a separate system.
UI
/dashboard/inbox— thread list with filters./dashboard/inbox/[threadId]— full conversation with the agent draft and approve / edit / reject controls./dashboard/inbox/review(review.vue) — focused review queue of items needing attention./dashboard/inbox/quarantine(quarantine.vue) — security-flagged messages.
Actions
- Approve — sends the draft as-is; status →
approved, thensentafter a confirmed provider dispatch. - Edit and approve — modify the draft, then send.
- Reject — status →
rejected. - Reassign — route to a different team member.
See the operator-facing Team Inbox and AI Agent & Autonomy guides for the full workflow.
Graduated autonomy
The autonomy and ops layer (tables in schema/inbox.ts) is now wired into the live routing decision, behind the ai.autonomy feature flag.
| Table | Purpose | Status |
|---|---|---|
autonomyRules | Per-category autoApproveThreshold + maxDailyAutoActions daily cap + isEnabled toggle | Live — the route step reads it via internal.autonomy.checkPermissionInternal (Tier 2); crons reset counts and adjustThresholds tunes them weekly |
autonomyFeedback | Human approve/reject/edit signals for threshold tuning | Live — inbox/mutations.ts approve/reject/edit call internal.autonomy.recordFeedback, feeding both the weekly adjustThresholds cron and the rejection_spike breaker |
agentMetrics | Rolling-window metrics | queue_depth, processing_latency, error_rate, auto_approve_ratio, rejection_rate, and llm_cost are all populated by agentHealth.ts:rollupMetrics. classification_accuracy is also populated by rollupMetrics as a mean self-reported-confidence proxy — there is still no human ground-truth label stream |
agentCircuitBreakers | Auto-trip safety on llm_failure / confidence_degradation / rejection_spike | All three are evaluated every rollup with hysteresis (open → half_open → closed); the route step refuses auto-approval while any breaker is open |
knowledgeBackfillJobs | One-time historical knowledge extraction | Live — kicked off when the ai.agent flag flips false→true (agent/knowledgeBackfill.ts) |
The route step decides in three tiers — circuit-breaker safety, per-category autonomyRules (behind the ai.autonomy flag), then the legacy global agentConfig threshold. Human approve / reject / edit decisions flow back as autonomyFeedback, which the weekly adjustThresholds cron uses to tighten or loosen per-category thresholds and the health rollup uses for the rejection_spike breaker. With ai.autonomy off, only the legacy global agentConfig governance (auto-reply toggle, confidence threshold, daily cap) applies.
Message coalescing
Email threads often arrive as bursts — a CC chain with three replies in 30 seconds. Coalescing collapses such a burst into a single pipeline run on the most recent message instead of running it per message.
apps/api/convex/agent/coalescing.ts is implemented. shouldCoalesce is a debounce: it cancels and re-schedules a per-thread processCoalescedBatch job coalesceWindowMs into the future (one coalesceBatches row per thread), so the batch only fires once the thread goes quiet for a full window. processCoalescedBatch then picks the newest still-received message as the leader, supersedes the older ones (archived with reason coalesced, their content still reaches the agent via thread history), and runs the pipeline once for the leader.
It is opt-in: coalescing is active only when agentConfig.coalesceWindowMs is a positive value. Unset (the default) means each message is processed immediately, with no added latency.
Multi-intent branching (future)
A future design lets a message with multiple intents (e.g. "check my billing status, and we'd love dark mode") fork into parallel worker branches, each producing its own draft, with the review queue showing both linked to the same inbound message.
The walker runs a single linear step chain and classify produces one Classification object — there is no fork / Worker-A / Worker-B mechanism today. See ADR-008 for the process-architecture direction.