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.

Status: core shipped, edges narrowing

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:

RouteMeaning
in_stateStay in the current processingStatus, schedule the next step.
transitionMove processingStatus to a new state (optionally schedule a follow-up step).
doneStop — 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.

One linear chain, not a fork

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.7quarantined (held for human release).
  • Spam score ≥ 80archived (reason spam).
  • Clean, agent enabled → transition to classifying and schedule context_retrieval.
  • Clean, agent disabled → record the scan and stop (done).
LLM injection classifier — shipped (defense in depth)

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 (inboundMessages by thread)
  • Knowledge graph — semantically relevant typed entries via internal.knowledge.retrieval.semanticSearch (the [KNOWLEDGE] section, capped by knowledgeEntryLimit)
  • Relevant source files — semantically relevant uploaded documents via internal.semanticFileProcessing.semanticSearch (the [RELEVANT FILES] section, capped by fileLimit)
  • 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:

TierTriggerApproach
normalFits within budgetPass all context verbatim.
compactedUp to 3× budgetKeep the tail of the assembled context (most recent material).
emergencyOver 3× budgetKeep only the contact line and the current message.
Knowledge graph context — wired

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: spamarchived; 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:

  1. Circuit-breaker safety gate. Any open breaker (internal.agentHealth.getCircuitBreakersInternal) forces human_review — a degraded pipeline never auto-sends, regardless of config.
  2. Graduated autonomy (when the ai.autonomy flag is on). internal.autonomy.checkPermissionInternal applies the per-category autonomyRules (threshold + daily cap + breaker check). A category with no rule is never auto-approved; a permitted one charges the per-category daily count.
  3. Legacy global fallback (autonomy flag off). The single agentConfig singleton — 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).

Graduated autonomy routing — wired

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
Don't confuse the two MTA webhooks

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:

  1. Upserts the sender as a contacts row (channel email, source inbound).
  2. Resolves or creates a conversationThreads row via the threading cascade.
  3. Inserts the inboundMessages row with processingStatus: 'received'.
  4. Records an inbound_received contact activity.
  5. Schedules internal.agent.walker.start, which kicks the pipeline off at security_scan.

Threading

Threading (owned by the thread module called from receiveMessage) is a three-strategy cascade:

  1. In-Reply-To header → find the referenced message's thread.
  2. References header → find any referenced message's thread.
  3. 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.

FieldTypeDescription
messageIdstringSMTP Message-ID
from, to, subjectstringEnvelope data
textBody, htmlBodystring?Message content
inReplyTo, referencesstring?Threading headers
headers, attachmentMetastring?Raw headers / attachment metadata (JSON)
threadIdid → conversationThreadsConversation thread
contactIdid → contactsLinked contact
processingStatusenumreceived, security_check, quarantined, classifying, drafting, draft_ready, approved, sent, rejected, archived, failed
securityFlagsobject?Pattern-scan results (injection, spam score, confidence)
classificationobject?Classifier result (category, priority, sentiment, intent, confidence)
draftResponse, draftSubjectstring?Agent-generated draft
confidenceScorenumber?Overall confidence for the routing decision
contextTierenum?normal / compacted / emergency
assignedTostring?Human reviewer (BetterAuth user ID)
errorMessagestring?Pipeline error detail
receivedAtnumberTimestamp the message was received
processedAtnumber?Timestamp the pipeline finished (set once processing completes)

conversationThreads

Groups related messages into conversations.

FieldTypeDescription
subject, normalizedSubjectstringDisplay subject / matching key
contactIdid → contactsLinked contact
contactIdentifierstringChannel-neutral display identifier — email for email/generic, phone/handle for SMS/WhatsApp/chat (renamed from contactEmail in ADR-0032)
statusenumopen, waiting, resolved, closed
assignedTostring?Assigned team member
latestDraftStatusenum?pending / approved / rejected / sent — for fast queue filtering
messageCount, firstMessageAt, lastMessageAt, createdAtnumberThread metadata

agentActions

One row per pipeline step execution (not per planned action).

FieldTypeDescription
inboundMessageIdid → inboundMessagesSource message
actionTypeenumsecurity_scan, context_retrieval, classify, draft, route (the step kinds)
statusenumpending, running, completed, failed, skipped
input, outputstring?Step input / output (JSON)
errorMessage, retryCountstring? / numberError tracking
startedAt, completedAt, durationMsnumber?Timing
modelUsed, tokenUsagestring? / 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.

FieldTypeDescription
isAutoReplyEnabledbooleanAllow auto-sending without human review
confidenceThresholdnumber (0–1)Minimum confidence for auto-approval
toneDescriptionstring?Organization communication style
signatureTemplatestring?Email signature for agent drafts
maxDailyAutoRepliesnumber?Daily auto-reply cap
dailyAutoReplyCount, dailyAutoReplyResetAtnumber?Rolling daily-cap counters
coalesceWindowMsnumber?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):

TaskTierUsed by
classifyfastClassification step
extractfastKnowledge extraction
guardfastSecurity-scan LLM injection classifier
summarizefastSummarization
draftcapableDraft generation step
plancapable(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.

VariableDescriptionDefault
LLM_MODELFallback model for all tasks
LLM_MODEL_CAPABLEModel for the draft (capable) tierFalls back to LLM_MODEL, then gpt-4o
LLM_MODEL_FASTModel 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, then sent after 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.

TablePurposeStatus
autonomyRulesPer-category autoApproveThreshold + maxDailyAutoActions daily cap + isEnabled toggleLive — the route step reads it via internal.autonomy.checkPermissionInternal (Tier 2); crons reset counts and adjustThresholds tunes them weekly
autonomyFeedbackHuman approve/reject/edit signals for threshold tuningLiveinbox/mutations.ts approve/reject/edit call internal.autonomy.recordFeedback, feeding both the weekly adjustThresholds cron and the rejection_spike breaker
agentMetricsRolling-window metricsqueue_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
agentCircuitBreakersAuto-trip safety on llm_failure / confidence_degradation / rejection_spikeAll three are evaluated every rollup with hysteresis (open → half_open → closed); the route step refuses auto-approval while any breaker is open
knowledgeBackfillJobsOne-time historical knowledge extractionLive — kicked off when the ai.agent flag flips false→true (agent/knowledgeBackfill.ts)
Autonomy is now a closed feedback loop

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.

Coalescing — shipped (opt-in)

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.

Not in the code

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.