The research agent works out who the people in the CRM are. It is an eve app, it is its own deployment, and it owns every piece of intelligence in this repo.
Read this with api.md — whose first rule is that none of this may
move into the API — and
plan/contact-intelligence-agent.md,
which is why it is shaped this way.
The complete eve documentation ships inside the package, matching the installed version exactly:
apps/agent/node_modules/eve/docs/README.md
Read the relevant guide there before writing eve code. Guessing at this API is expensive in a specific way: it typechecks, builds, and then behaves differently from what you assumed — see the note on principal mapping under the bridge.
The agent runs on zai/glm-5.2-fast by default, and a rep can change that
on the settings page without touching the code.
DEFAULT_AGENT_MODEL lives in @crm/db/settings
because two processes need the same answer — the agent, to compile its
fallback, and the API, to tell the settings page what "Default" resolves to.
A second copy of that string is a second answer to the question.
- The choice is a row, not an env var.
AppSettingholds one record, andagent.tsresolves it throughdefineDynamiconsession.started— so a change applies to the next session rather than the next deployment, which matters in an install whose owner cannot redeploy. A conversation already open finishes on the model it started with, and that is deliberate: prompt caches are per model, so switching part-way re-ingests the thread at uncached prices. - The window travels with the id. eve never inherits
modelContextWindowTokensfrom the fallback, solib/model.tsalways sends it. A model with a smaller window than the default would otherwise be compacted against a number it does not have, and the turn fails at the provider after the context has been assembled and paid for. - A failed read degrades, it does not throw. No row, no database, a
resolver that raises — all of them leave the compiled fallback in force.
lib/model.tslogs the reason, because the alternative is a session quietly running on a model nobody chose. - The chooser only offers models this agent can run. Every read the agent
has is a tool, so
ModelCatalogServicefilters the gateway catalog to language models taggedtool-use. An unreachable gateway makes the chooser read-only rather than failing the page, and the id already stored keeps running. - Not a frontier model, on purpose. The hard part of this job is refusing a plausible-looking wrong answer, and that is enforced by the tools and the evidence model below rather than by model strength. What the job does want is a long window — a company preamble hands over every contact on the account — and answers fast enough that the sheet's Agent tab reads as a conversation.
A logo and a profile photograph both arrive as somebody else's URL, and neither may be stored as one. LinkedIn signs its CDN URLs with an expiry roughly three weeks out; a brand's own CDN is fine until it is a rate limit on a page drawing forty logos. Either way the failure is the same and it is the worst shape a failure can take here — a picture that works today and is a broken image next month, with nothing to connect the two.
So mirror() copies the bytes into Vercel Blob and the record points at our
copy.
It lives in @crm/db/blob, not in the agent.
It started here, on the reasoning that whoever writes the URL onto the record
should own fetching the bytes — which is still right, and is exactly why it had
to move once more than one thing wrote one. There are four writers now, in three
processes:
| Writer | What it copies |
|---|---|
lib/brand-images.ts (agent) |
The four artwork columns on a company |
lib/portrait.ts (agent) |
A contact's photograph |
FaviconService (API) |
The icon a domain's own site serves |
ImageMirrorService (API) |
Anything on an existing row still pointing off-site, including a signed-in user's Google avatar |
packages/db/prisma/seed.ts is the fifth, and it matters more than it sounds:
demo rows outlive their sources, and a clone seeded today is still open in six
months.
- The key carries a hash of the bytes. That is what makes re-running both idempotent and correct: unchanged artwork lands on the same URL and the record does not move, while a brand that redesigns its mark gets a new URL rather than the same one behind a month of CDN cache.
- One list of the columns that hold a picture,
COMPANY_IMAGE_FIELDSin@crm/db/images. Four things read it and a column missing from one of them is a picture that is never brought in-house, with nothing anywhere to say so. - It fetches through
@crm/db/safe-fetch, which is the favicon resolver's own guard, exported rather than copied. The URL came from a vendor's answer about a domain a rep typed, so a logo link pointing at169.254.169.254is a request forgery from inside our network. One copy of that rule, several callers. - No
BLOB_READ_WRITE_TOKENmeans no photographs, and everything else keeps the origin's URL. The distinction is deliberate: a face comes from a signed CDN link that expires, so storing one would promise a picture that breaks in three weeks, while a logo or a favicon merely stays somebody else's to serve. It is an optional capability like every other, and it is inlib/capabilities.tsso the agent is told before it plans. - A mirrored URL is what lets the browser optimize it.
isOptimizablein@crm/db/imagesis the whole of that decision and both halves are load bearing:next.config.tsallow-lists our Blob host and nothing else, because a wildcard would make the deployment an open image proxy — and a mirrored SVG is still refused, because Blob's separate origin is the only reason we store SVG at all, and/_next/imagewould re-serve those bytes from ours. Everything that fails the check renders as the plain<img>it always was. - Faces are not optimized, and that is not an oversight.
EntityLogouses<Image>;AvatarImagedeliberately does not. Radix establishes an avatar's load state by probing the URL with its ownnew window.Image(), so an optimized child would request a second, different URL — two fetches per face on a table drawing forty. There is nothing to win for the second one either: every photograph we store arrives pre-thumbnailed by its source (shrink_100_100,=s96-c), while brand artwork arrives at whatever size the brand publishes. - A photograph is only ever taken from a source already tied to this person.
lib/portrait.tsholds the write andlib/portrait-sources.tsholds the chain, in order of certainty: their LinkedIn profile, their GitHub account, their employer's own team page. Every one is keyed on an identifier already on the record, so each asks what does this account look like rather than who is this name. - There is no image search by name, and there must never be. It is the obvious fourth step and the one that breaks everything else. A search for "Paula Marchetti" returned Brightwater's CEO, an HR lead at Reply and a data engineer in Seattle, all confidently — and those were names, which a rep can read and smell. Nobody audits a face. Guess where to look, never what you will find: a team page is a guess about a URL, and the name printed beside the photograph is the answer.
get_linkedin_profilestores the picture itself the moment it computesisSamePerson, in code, rather than asking the model to remember a follow-up call.
schedules/dispatch.ts runs portrait rows directly, without receive.
This is the one kind that skips the model, and it is worth knowing why: the work
is three reads keyed on identifiers already on the record and a byte copy, with
nothing in it to decide.
Routed through a session it also did not work. Seven queued faces sat behind sixty LLM sessions at five a minute and had not landed twenty five minutes later, each one waiting to pay for a context window in order to make no decisions with it.
The schedule still decides nothing, which is the rule it has to keep. The row says what the work is; the branch only says whether it needs a conversation.
Enrichment is queued when a record is created, so anything created before a capability existed never gets it. Two routes, and picking the wrong one is expensive:
| Covers | Cost | |
|---|---|---|
| Automatic sweep, on sign-in | Records never successfully looked up | Ten credits per company |
ImageMirrorService, in the same sweep |
Rows whose picture is still served from somebody else's origin | Nothing |
bun run --filter=agent backfill:images |
Records already enriched, missing only the pictures | Nothing |
The middle row is the one that keeps "every picture is ours" true rather than true-since-Tuesday. Every writer copies as it writes, but that says nothing about rows written before it did, or rows whose copy failed at the time because the origin was down. Left alone those never resolve, and nothing looks broken — the pictures render perfectly, off someone else's server, forever. It is capped at twenty five rows per table per sweep, so a backlog drains over several sign-ins instead of in one burst that looks like a scrape.
**A photo sweep queues anyone with a LinkedIn URL, a GitHub URL, or an employer
with a website — the three doors the chain knows. The third reads the company's
team page and costs Context.dev credits, which is why a finished portrait
task stands that contact down for thirty days. Most people are not on their
employer's team page and never will be; without the stand-down every sweep would
pay to re-read the same sites and find the same nothing, forever. The task's
outcome carries what was actually tried, so a month of paid lookups leaves
something readable behind.
The sweep has no button, deliberately.** It had one on each list page, and a control whose correct usage is "press it whenever you notice blanks" is a chore dressed as a feature — a rep has no way of knowing which records predate the favicon resolver, and no business fixing that by hand.
The trigger is signing in, which is the one moment nothing else in the app
can see. packages/auth owns it, through a Better Auth
databaseHooks.session.create.after; BackfillService.onModuleInit subscribes
via onSignedIn. The registry exists because the arrow cannot point the other
way — @crm/auth is a dependency of the API, so it must not import a Nest
provider — and it means the Next.js app, which imports the package only to read
sessions, runs none of this.
BackfillService has no router at all as a result. Nothing calls it; it is
subscribed. A five-minute stand-down collapses a burst — a team arriving at
nine, or Google linking an account mid-sign-in — and auto() returns before any
work starts, so nothing is between a person and their CRM.
It writes AgentTask rows and decides nothing, which is what keeps it on the
API's side of the line in api.md; whether a given company is worth
the credits stays the agent's call. Each pass is capped at 500 rows and the
leftover is logged rather than rounded away.
The script is the cheap half: it re-derives from the CompanyEnrichment.raw
payloads already on disk, which is what keeping them was for. Reach for it when
the records are fine and only the images are missing.
No tool accepts a confidence, a score, or a sourceUrl offered as proof. A
tool reports what it observed — crm.signature-block, github.account-identity
— and lib/evidence.ts prices it. This is the rule the whole design rests on:
a model asked to grade its own certainty will, and it will be wrong in the
direction that makes it look useful.
lib/evidence.ts— the weights, the combination rule, the bands.lib/facts.ts— the only write path to a contact's fields. Applies atVERIFIED, stores a proposal below it, and enforces three things a prompt cannot: never overwrite a human, never re-offer a dismissal, never write without a primary source.- The bands are behaviour, not labels.
PROBABLEmeans a rep decides, and that is a correct outcome — four Marchettis work at Fernhill.
Adding a fact field means adding it to FIELDS in lib/facts.ts and to
FACT_COLUMNS in apps/api/src/contacts/contacts.service.ts, which is where an
accepted proposal writes through.
Every outside source is optional and the agent is designed to run with none.
lib/capabilities.ts is the single place that knows what is set: it prints the
list at boot, states it in the session instructions so the agent plans around
what it has, and gives tools a shared "not configured, and retrying will not
help" result — checked before the research budget is charged.
A missing key removes a place to look. It is never an error, and it must never throw.
lib/focus.tsholds the per-session budget indefineState. Every vendor call charges it. Running out is a normal ending.lib/tasks.tsis the work queue.claimDueleases rows withFOR UPDATE SKIP LOCKED, so two dispatchers take disjoint work and a run that dies frees its row when the lease expires.schedules/dispatch.tsis the only schedule. It decides nothing: it leases what is due and starts a session per row. Anything that looks like "every N minutes, the oldest ten contacts" belongs in a task'sdueAt, not in a cron expression.tools/schedule_recheck.tsis how the agent books its own next look, and itsreasonis shown to the rep. An agent that cannot say why it will be back in fourteen days does not have a reason, it has a default.
The CRM has contacts, companies and deals, and the agent is opened on any of them. Every read hands back the ids of the neighbouring records, and that rule is not decoration — the two worst answers this agent has given both came from breaking it:
I don't have a tool that lists contacts by company, only ones that look up a specific contact by ID or email. Could you paste the contact's name or email address?
Said to a rep who had the company open, with the contacts on screen. The
company preamble reported a count of contacts, the only read took a
contactId, and the agent correctly concluded it had nowhere to go. And, on a
contact who plainly worked somewhere, that no company was available: the
history returned companyName, a display string, while every company tool
takes an id.
So:
| Read | Free | Hands back |
|---|---|---|
read_crm_history |
yes | the contact's company id, the deals they are on, their colleagues |
read_company_history |
yes | every contact with their id, every deal, the account's threads and meetings, the notes |
read_deal_history |
yes | the stage clock, every stage it moved through, the people on it with ids, the last reply |
search_crm |
yes | contacts, companies and deals matching what a person would type |
A preamble or a tool result that names a record without its id is a bug, because the only recovery available to the agent is to ask the human — which is the CRM handing its own join back to the person using it. Ambiguity is different, and fine: four Marchettis come back as four rows with their titles, and asking which one is a question rather than a chore.
search_crm deliberately does no fuzzy matching. "Northwind" reaching
"Northwind Savings Group" is useful; "Marchetti" reaching "Marchetta" is a
wrong record in a CRM, and a wrong record about a real person is the one
failure this whole design exists to prevent.
lib/preamble.ts builds what a session is told before it speaks, and it varies
on two axes:
- Which record. A person is asked who they are, a company what it does and who we know there, a deal where it stands. Each preamble names that record's neighbours, with ids, and points at the read to start from.
- Who opened it. A dispatched task is a research pass with a budget; a rep
in the sheet is a conversation. Told neither, the agent assumed the first,
which is how a question got a work plan back.
taskKindis the tell — the dispatcher sets it and the panel never does.
The prose lives in lib/preamble.ts rather than in instructions/task.ts so
that test/preamble.integration.spec.ts can assert the thing that actually
matters: that a company session can name its own contacts. task.ts is the
resolver, and it owns the one side effect — seeding lib/focus.ts, without
which the audit hook files a session's events against nothing.
Adding a fourth record kind is an entry in sessionPreamble, a read beside the
other three, and a line in TOOL_VERBS (apps/app/lib/agent-transcript.ts),
which a test enforces so no tool ever shows a rep a bare slug.
It may read everything, including full email bodies — single-tenant internal tool, and a signature block is the best source of a job title there is. The boundary is egress, and it is three rules:
- No customer text in a third-party query. Derived questions only.
- Nothing from a mailbox into
/workspace. The sandbox has a different lifetime. - Nothing sensitive logged. Reading is not logging.
skills/data-boundaries.md is the agent's copy of this. Keep them in step.
agent/sandbox/sandbox.ts turns on bash, the file tools, and a /workspace,
with deny-all egress set on the backend factory so it cannot be forgotten
per session. That costs nothing: web_fetch runs in the app runtime and
web_search at the model provider, so retrieval is unaffected.
Never give the sandbox DATABASE_URL. CRM access is authored tools in the
app runtime. A shell with credentials and network is exfiltration-shaped even in
an internal tool; a shell with neither is a text processor.
The contact sheet's Agent tab talks to a running agent. The path:
browser → /eve/v1/* (same origin, session cookie)
+ x-crm-contact: the record the rep has open
→ apps/app/app/eve/v1/[...path]/route.ts
checks the Better Auth session
strips the cookie
mints a 2-minute HS256 token naming the rep
and carrying the contact id
→ AGENT_URL/eve/v1/*
agent/channels/eve.ts → repFromCrm() verifies it
instructions/task.ts reads attributes.contactId
The record travels in the token, never in the message. The panel used to
prefix everything a rep typed with About contact <cuid> (Name): so the agent
knew what it was looking at, which meant the rep read their own question back
with plumbing bolted to the front. The claim reaches the agent through the same
session.auth.attributes path the dispatcher uses, so the message stays theirs.
Mounted at /eve/v1/* deliberately: that is where useEveAgent() looks by
default, so the hook needs no host and there is no CORS and no cross-site
cookie anywhere in it. It is the same trade the app already makes for the API.
Three things worth knowing before you touch it:
- The proxy is an enforcement point, not a passthrough. The agent never sees the session cookie, so if that route did not check the session, nothing downstream would.
- eve's
jwtHmac()helper resolves an HMAC token toprincipalType: "service"with a namespacedprincipalId. Correct for a machine credential, wrong for a person — andlib/approval.tsdecides whether to pause for a human by reading exactly those fields, so a rep would have been refused a sensitive write while sitting there watching.repFromCrmwrapsverifyJwtHmacand maps the subject to a real user principal.test/channel-auth.spec.tspins it. AGENT_BRIDGE_SECRETunset skips the auth entry rather than opening it. The panel stops working; the agent keeps running its own schedule. An optional capability's absence must never widen access.
lib/agent-record.ts is the single place that maps a record kind to everything
downstream: the header the panel sends, the claim the proxy mints, the field a
conversation is filed under, and the questions offered on an empty thread. A
contact is asked "Who is this person?", a company "What do they do?", a deal
"Where does this stand?" — offering the first of those on a company is the tell
that a chat box was bolted on rather than built into the record.
The agent gets the same context: instructions/task.ts opens a session with a
preamble built from the record, so a deal session starts knowing the stage, the
amount, the close date and who is on it, rather than spending its first two tool
calls finding out.
Adding a fourth kind is one entry in COPY plus a branch in the agent's
preamble — not four edits in four layers.
A record accumulates conversations, and they survive a reload. AgentConversation
holds the handle — the durable eve session id plus its cursor — while the
transcript itself is already in AgentEvent, written by the audit hook. Nothing
is stored twice.
-
Resuming. The panel passes the saved cursor as
initialSession, so reopening a contact continues last week's thread rather than starting another. eve keeps sessions for 30 days. -
Replay from the start.
streamIndex: 0on resume, deliberately — the saved index is where the last reader stopped, and a reopened thread should show what was said in it, not only what has happened since. -
Which thread is open lives in the URL (
?thread=), like every other view state in the sheet, so a refresh keeps your place and a conversation is a link. It is cleared when the record or the tab changes, by the same rule that drops a half-typed quick-add form. -
Nothing mounts until the list has loaded. Rendering a thread while the history is still in flight starts a new eve session and then remounts onto the real one — which presents as "the history only appears if I refresh".
-
The thread the panel landed on is captured once. Re-deriving "the latest" as the list changes would swap the open conversation out from under a live answer the moment the first save adds a row.
resolveThreadinlib/agent-transcript.tsholds the rule, and it is tested. -
The panel is not unmounted when you switch tabs. It holds a live stream, and Radix drops an inactive tab by default — which aborts the stream mid-answer, so the reply landed in the durable session with nothing attached to receive it. That is the "I went to another tab and the answer never came back" bug, and no amount of re-reading state on the way in could fix it, because the events had been dropped.
keepMountedon the tab descriptor (detail-sheet.tsx) keeps it alive; it renders nothing until the tab is opened once, so flicking through records costs nothing. -
A thread is loaded with
session.snapshot(), not by hand. One call returns the complete event prefix, the cursor that continues from it, and a continuation token if and only if eve will accept another turn — about 30ms against a hundred-event thread.lib/agent-session.tsis the whole of it.What it replaced is worth remembering, because every panel bug of the last day came out of it: a raw
fetchof…/stream?startIndex=-1, parsing the last line into a state machine. The endpoint follows, so awaiting the body never returned. The stream opens with a bare newline, so "the first line" was empty — which failed closed to "busy" and locked every reopened conversation with "still working on the last question", including ones parked with a perfectly good token. And a read that failed reported the session as working rather than reporting itself as broken, so it could never recover. The framework had a documented answer to the exact question that code was asking. Read the guide before hand-rolling the protocol. -
The token is the authority on whether a message can be sent, not our reading of the events. eve returns one only when the captured prefix ends parked, which is precisely the condition under which the next send lands.
-
A turn that has gone quiet for 90 seconds is over, not working. A restarted agent leaves sessions with no closing boundary; they never park, and treating them as in-flight locks that thread forever.
-
An unreachable agent is
offline, notworking. One is a fact about us and the other a claim about the session; stated as the latter it is both untrue and unrecoverable, since the read fails identically next time. The transcript then comes from our ownAgentEventarchive — which is also what makes a thread older than eve's 30-day retention still readable — and the composer stays usable. -
An ended thread gets a button, not a locked box. Ended and working both disable the composer and mean completely different things: one is a wait of seconds, the other is permanent.
composerState()keeps them apart, and an ended thread offers Start a new conversation, which moves the picker to a new thread. The transcript stays on screen throughout, and the save hook treats the fresh session as a new conversation by comparing session ids rather than by whether the panel started empty. -
autoScrolland nothing else. The scroller is a state machine (following-bottom,free-scrolling,anchored-to-message) andscrollAnchorselects the third, which stops it following the bottom — the answer then streams below the fold while the modes fight over each new row. Left alone,autoScrollfollows the tail while the reader is at the bottom and releases the moment they scroll away, which lights the jump-to-end button. -
One
MessageScrollerItemper message, not per part. The row is what the scroller measures; a row per tool call adds a boundary every few hundred milliseconds during an answer. Part ids prefertoolCallId, which is stable across a call's streaming states. -
A thread nobody has spoken in is loaded from nothing. The snapshot query is disabled without a conversation; a brand-new thread mounts with no session and no events, and its first message creates both.
-
Scoped to the rep. Two people asking about the same contact are having two conversations.
ConversationsServicefilters on the caller, and a session id in a request body decides which row, never whose. -
Cached the way
api.mdprescribes: read throughcache-manager(Redis whenREDIS_URLis set), write on miss, explicit invalidation on every save. The list is read on every sheet open and changes only when somebody sends a message, which is the shape a cache is for.
This lives in the API rather than the agent, and that is not a breach of rule one: listing a record's history researches nothing, scores nothing and decides nothing. The agent owns judgement; the data surface owns filing.
Same value in both processes, from the one root .env:
AGENT_URL="http://127.0.0.1:2000" # the default
AGENT_BRIDGE_SECRET="$(openssl rand -base64 32)"Then bun run dev (the agent serves on :2000) and open any contact.
If the Agent tab errors:
| Symptom | Cause |
|---|---|
503, "not configured for this install" |
AGENT_BRIDGE_SECRET is unset in the app's process |
401 |
The two processes hold different secrets |
502, "not reachable" |
The agent is not running, or AGENT_URL is wrong |
eve dev takes about five seconds to bind, and it listens on IPv4 only.
That is why AGENT_URL defaults to http://127.0.0.1:2000 rather than
http://localhost:2000: Node resolves localhost to ::1 first, so the
localhost form fails to connect on a machine where the agent is plainly
running — and reports itself as "not reachable", which sends you looking in the
wrong place.
A variable in .env is not enough on its own: Turbo runs in strict env mode, so
apps/app/turbo.json and apps/agent/turbo.json both declare the pair in
passThroughEnv. Adding a variable and not declaring it produces exactly the
401 above.
localDev() accepts anything on loopback, so a bare curl to 127.0.0.1
proves nothing about the bridge. Send a non-loopback Host to make that entry
skip:
curl -s -o /dev/null -w '%{http_code}\n' \
-H 'Host: agent.example.com' \
http://127.0.0.1:2000/eve/v1/info # 401
curl -s -H 'Host: agent.example.com' \
-H "authorization: Bearer $TOKEN" \
http://127.0.0.1:2000/eve/v1/info | jq '.tools.available | length'GET /eve/v1/info is the whole inventory — tools, skills, schedules, channels,
sandbox, and a diagnostics count that is the fastest way to find a file eve
silently ignored.
bun run --filter=agent test. The integration specs need DATABASE_URL and run
against a real Postgres, which is the point — "never overwrite a human" is only
true if the transaction says so.