Skip to content

Architecture

Matt Dula edited this page Apr 18, 2026 · 2 revisions

Architecture

A visual tour. See docs/ARCHITECTURE.md in the repo for the canonical copy; this page mirrors it with extra context.

Component overview

%%{init: {"look": "handDrawn", "theme": "dark"}}%%
flowchart LR
    Claude[Claude Desktop]
    ChatGPT[ChatGPT]
    Cursor[Cursor]
    Perplexity[Perplexity]
    CLI[curl / scripts]

    Claude & ChatGPT & Cursor & Perplexity --> MCP
    CLI --> REST

    subgraph Nakatomi[Nakatomi CRM]
        direction TB
        REST[REST API]
        MCP[MCP server]
        Worker[Webhook worker<br/>background thread]
        Schema[/schema + llms.txt + agent.json/]
    end

    REST & MCP & Worker --> PG[(Postgres)]
    REST --> Storage[(Files<br/>local or S3)]
    REST & MCP -.->|optional| Memory[Memory connectors]
    Worker -.->|signed POST| Sub[Subscribers]
Loading

What runs in-process:

  • REST API + MCP server (same FastAPI app, one DB pool)
  • Webhook worker thread — started in lifespan, claims pending rows via SELECT ... FOR UPDATE SKIP LOCKED
  • Memory connector HTTP clients — created at startup per enabled adapter

What's external:

  • Postgres (any 13+ will do)
  • File storage (local volume or S3-compatible)
  • Memory providers (DocDeploy, Supermemory, GBrain)
  • The subscribers on the other end of your webhooks

Durable webhook delivery

%%{init: {"look": "handDrawn", "theme": "dark"}}%%
sequenceDiagram
    participant Route
    participant Emit as emit()
    participant DB as Postgres
    participant Worker
    participant Target as subscriber

    Route->>Emit: contact.created
    Emit->>DB: INSERT WebhookDelivery<br/>status=pending
    Note over Worker: poll every 5s
    Worker->>DB: claim pending
    Worker->>Target: POST signed
    alt 2xx
        Worker->>DB: status=succeeded
    else 5xx / timeout
        Worker->>DB: attempts++<br/>next_attempt_at += backoff
        Note over Worker: after WEBHOOK_MAX_RETRIES<br/>→ status=dead
    end
Loading

Retry backoff schedule: [2, 8, 30, 120, 300] seconds. Tunable via WEBHOOK_MAX_RETRIES (default 3). See Webhooks for the wire format.

Ingest pipeline

%%{init: {"look": "handDrawn", "theme": "dark"}}%%
flowchart LR
    Agent[Agent /<br/>upload] -->|POST /ingest| Dispatch{format?}
    Dispatch -->|csv| A1[csv adapter]
    Dispatch -->|json| A2[json adapter]
    Dispatch -->|vcard| A3[vcard adapter]
    Dispatch -->|text| A4[text adapter]
    A1 & A2 & A3 & A4 --> Norm[normalize]
    Norm --> Dedupe[dedupe via external_id]
    Dedupe --> CRM[(rows)]
    Dedupe -.->|ingest.completed| Timeline[[timeline + audit]]
Loading

See Ingest for adapter details and custom mappings.

Memory cross-linking

%%{init: {"look": "handDrawn", "theme": "dark"}}%%
flowchart LR
    W[CRM mutation] --> Emit
    Emit --> TL[(timeline)]
    Emit --> WH[webhook queue]
    Emit -.->|configured connectors| MC[DocDeploy /<br/>Supermemory /<br/>GBrain]
    MC --> Link[(MemoryLink)]

    R[memory_recall query] --> Fan[fan-out]
    Fan --> MC
    MC --> Merge[merge + score]
    Merge --> Out[results + crm_links]
Loading

Every CRM write gets mirrored outbound to every enabled connector. Every recall fans out across them and decorates results with the crm_link references already on file.

Export / import round-trip

%%{init: {"look": "handDrawn", "theme": "dark"}}%%
flowchart LR
    A[(Workspace A)] -->|GET /export| Doc[["json doc"]]
    Doc -->|POST /import| B[(Workspace B)]
    Doc -.->|portable| Any[any Nakatomi]
Loading

See Export-Import for the doc schema and merge rules.

Why this shape

  • One Postgres, one process. No Redis, no Kafka, no eventual consistency to explain. The webhook queue and rate-limit counters live in the same DB as the CRM rows.
  • Worker in-process for v1. SKIP LOCKED makes it safe to scale horizontally. Pulling the worker into its own process is a zero-logic refactor when volume justifies it.
  • Agents discover through /schema. No SDK stubs; no generated clients. Ask, learn, act.
  • Memory connectors are plural. You can enable a cheap one (Supermemory) and a self-hosted one (GBrain) at once. Recall merges across both.

Clone this wiki locally