An eve app, its own deployment, owning every piece of
intelligence in this repo. Read with api.md, whose first rule is that none of this
may move into the API. Local dev, the bridge env vars and the manual dispatch command
are in docs/setup.md.
Read the eve guide before writing eve code —
apps/agent/node_modules/eve/docs/README.md matches the installed version;
.agents/skills/eve is the skill. Guessing typechecks, builds, then misbehaves.
Default zai/glm-5.2-fast; DEFAULT_AGENT_MODEL in @crm/db/settings because the
agent and the API both need it.
- A row (
AppSetting), not an env var, viadefineDynamiconsession.started. Open conversations keep their model — prompt caches are per model. lib/model.tsalways sendsmodelContextWindowTokens; eve never inherits it.- A failed read logs and keeps the compiled fallback. Never throws.
- The chooser offers only
tool-usemodels (ModelCatalogService). - Not a frontier model, deliberately — refusing wrong answers is enforced by the tools and evidence model, not model strength.
mirror() copies bytes to Vercel Blob; the record points at our copy. Lives in
@crm/db/blob — writers are lib/brand-images.ts, lib/portrait.ts,
FaviconService, ImageMirrorService, prisma/seed.ts.
- The key hashes the bytes — idempotent, and a redesigned mark gets a new URL.
COMPANY_IMAGE_FIELDS(@crm/db/images) is the one list of picture columns.- Fetch through
@crm/db/safe-fetch— vendor URLs are SSRF vectors. - No
BLOB_READ_WRITE_TOKENmeans no photographs; logos keep the origin URL. isOptimizable(@crm/db/images) is the whole rule:next.config.tsallow-lists only our Blob host (a wildcard makes us an open image proxy), and a mirrored SVG is still refused.- Faces are not optimized —
AvatarImageskips<Image>because Radix probes the URL itself, doubling fetches. - A photograph only comes from a source already tied to this person —
lib/portrait-sources.ts: their LinkedIn, their GitHub, their employer's team page, each keyed on an identifier already on the record. - There is no image search by name, and there must never be. Nobody audits a face. Guess where to look, never what you will find.
schedules/dispatch.ts, split by DIRECT_KINDS in @crm/db/agent-tasks.
| Kinds | How | Per tick | |
|---|---|---|---|
| Visible | brand, portrait |
Directly — no receive, no model |
60, six at a time |
| Research | everything else | One eve session per row | 12 |
Neither visible kind has anything to decide, and through a session they queued
behind sixty LLM runs for 25 minutes (test/lanes.integration.spec.ts). The row says
what the work is; the lane only says whether it needs a conversation.
Priority: brand 900 · portrait 800 · workspace 500 · requested 300 ·
meeting 200 · identify 100 · sweep 50 · companyProfile 40 · recheck 0. The
top two are what a rep reads before deciding what to open.
claimDue sorts what it claims — Postgres does not order UPDATE … RETURNING by
its sub-select's ORDER BY.
POST /internal/crm/dispatch drains both lanes; AgentTriggerService.poke() calls
it after writing any AgentTask.
- Fire-and-forget, never awaited — the row is still the message.
- Both lanes. Visible-only made them diverge under
eve dev, where there is no cron: logos resolved instantly whileidentifysat atattempts = 0forever. - Calls the channel's own
send, notreceive— it is already on the crm channel. Principal fromAPP_AUTH(lib/app-auth.ts);taskAuth()keeps schedule and route from drifting. drainAllcollapses viacollapsing()(lib/pool.ts) — forty new contacts poke forty times, andclaimDuehands each a disjoint batch. Per-process; cross-process overlap is leases andFOR UPDATE SKIP LOCKED.- An abandoned sweep is still in flight, and
dispatchHealth()says so. The timeout aborts the lanes, which stop between items, but the sweep only leavesunsettledSweepswhen it truly settles. Until then health reportsrunning: trueand the endpoint answers503, and a new sweep is refused rather than stacked on top of stuck work. - A slow
start(task)is reconciled, never failed. Pastsweep.startTimeoutMsthe lane stops waiting, the record stays Researching, and the late session id is attached bynoteSessionwhen the send lands. Only a send that actually rejects settles the recordFAILED; a send that never lands is retired byretireExhaustedafterMAX_ATTEMPTS. A session id that never reaches the row afterresearch.link.attemptsraisesunlinkedSessionsin health, so a task running under a session nobody recorded is visible. AGENT_BRIDGE_SECRETunset refuses rather than opens.
Probes a candidate Context key → valid/invalid/unknown. No session, no model, no
task row; exists because the API may not call Context.
- The probe is free and chosen to be — a free-provider address gets a
422before billable resolution. Do not point it at a real domain: ten credits per typo. classifyKeyrejects on401and nothing else.- The candidate key, never the stored one.
sweepBlankFacts (lib/blank-facts.ts) applies every pending suggestion whose field is
still empty and clears the ones that have stopped saying anything. It runs at the top of
schedules/dispatch.ts, every minute, over every contact in the workspace — it is a
database pass with no session, no model, no task row and no credits, so there is nothing
to ration and nobody to scope it to.
- Scans 2000 suggestions and fills at most 500 a pass, and reports what it did not
reach (
unscanned) rather than a clean sweep it did not make. - Idempotent — a second pass fills nothing, because those fields are no longer blank.
- The suggestions left are conflicts, every one against a value already on the record. That number should stay small and reads as work for a rep.
- It does not belong on sign-in, and that is not a preference. It lived there for one
release and never once ran. Two reasons, either fatal:
onSignedInfires onsession.create, so a rep already signed in never triggers it; andBackfillServicedoes its work in a detachedvoid (async () => …)()after the response, on a Nest API that is a Vercel serverless function (apps/api/api/index.ts) — the tail of that chain is not guaranteed to run at all. A cron in the agent is the only trigger here that is a fact rather than a hope.
Sign-in sweep covers records never looked up (10 credits/company);
ImageMirrorService in the same sweep re-hosts off-site pictures (free);
backfill:images fixes enriched records missing only pictures (free);
backfill:facts is the blank-field sweep above run by hand, with --dry to read it
first — the cron covers it, so this is for a machine pointed at another database.
- The image sweep keeps "every picture is ours" true, not true-since-Tuesday. 25 rows/table/sweep.
- Nobody clicks a suggestion into a blank field. The rule is enforced at the write path, so no queue forms; the sweep is for rows written before it existed and for the field a rep clears while a suggestion is pending. An unreachable agent leaves the suggestions where they are — the API cannot apply one itself.
- A finished
portraittask stands that contact down for thirty days — that third source costs credits and usually finds nothing. - No button, deliberately — a rep cannot know which records predate a resolver.
- The trigger is signing in, via
databaseHooks.session.create.afterinpackages/auth;BackfillServicesubscribes withonSignedInand has no router (@crm/authmust not import a Nest provider). Five-minute stand-down, andauto()returns before work starts. - The 500-row cap is on the pass, not each query in it. Deduplicate the union, cut
to 500, count
remainingagainst the union.
No tool accepts a confidence, a score, or a sourceUrl offered as proof. Tools
report what they observed (crm.signature-block, github.account-identity) and
lib/evidence.ts prices it. A model asked to grade its own certainty will, and will be
wrong in the direction that looks useful.
lib/facts.tsis the only write path to a contact's fields. Applies atVERIFIED, proposes below it, and enforces three things a prompt cannot: never overwrite a human, never re-offer a dismissal, never write without a primary source.- A band decides only when there is something to lose. An empty field is filled by
whatever cleared the floor for keeping, whatever the band — approving a sourced guess
into a blank is a click that can only say yes, and a rep with four hundred contacts
reads none of them.
fillsBlankis the whole rule: no human value in the way and nothing already found.PROBABLEstill means a rep decides when the field is already filled, which is the case where a wrong answer costs something. The dispatch tick applies the same rule to rows that predate it —sweepBlankFacts, above. - Applying settles the field's other suggestions. They were all offers to fill the
same blank, and the sheet shows one at a time — so left alone, accepting one reveals
the next, forever. The same rule holds when a rep accepts one (
decideFact). - The same value is never offered twice. A second
PROPOSEDrow with a value already waiting is refused at the write path, not deduplicated on read. - "The same value" is
sameValue, and a URL is compared as an address, not a string.canonicalValuelowercases, collapses whitespace, and forhttp(s)drops the scheme,www., a trailing slash and the query, and readstwitter.comasx.com. Compared byte for byte,…/in/pogrebs/is not…/in/pogrebs, so the record showed a suggestion offering back the URL already in the field — the exact click this whole rule exists to remove. The stored value is still what the source said; only the comparison normalises. The refusal is for offers only — the check runs afterappliesis decided, so evidence that has reachedVERIFIEDsince the offer was made still lands, settles the suggestion it matches, and replaces the older value. Refusing it there left a weaker value on the record with the answer sitting unread beneath it. - A new fact field goes in
FIELDS(lib/facts.ts) andFACT_COLUMNS(apps/api/src/contacts/contacts.service.ts).
lib/capabilities.ts is the single place that knows what is set: prints it at boot,
states it in the session instructions, and gives tools a shared "not configured,
retrying will not help" result — checked before the research budget is charged. A
missing key removes a place to look. Never an error, never throws.
capabilities() is async because the Context key is a row;
capabilitiesFrom()/markdownFor() are the pure halves. contextDevKey() is the only
resolver, and lib/context-dev.ts memoises its client on the key string.
lib/focus.ts— per-session budget indefineState; running out is a normal ending.lib/tasks.ts—claimDueleases withFOR UPDATE SKIP LOCKED.schedules/dispatch.tsis the only schedule and decides nothing. "Every N minutes, the oldest ten contacts" belongs in adueAt.tools/schedule_recheck.ts— itsreasonis shown to the rep.
Every read hands back the ids of neighbouring records. Breaking this made the agent ask a rep who had a company open, contacts on screen, to paste an email.
| Read (all free) | Hands back |
|---|---|
read_crm_history |
the contact's company id, their deals, their colleagues |
read_company_history |
every contact with id, deals, threads, meetings, notes |
read_deal_history |
stage clock and history, people with ids, last reply |
search_crm |
contacts, companies and deals matching typed text |
A preamble or tool result naming a record without its id is a bug — the only
recovery is asking the human. Ambiguity is fine: four Marchettis is four rows with
titles. search_crm does no fuzzy matching — "Marchetti" reaching "Marchetta" is a
wrong record about a real person.
lib/preamble.ts varies on which record (each names its neighbours with ids and
points at the read to start from) and 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 answered a question with a work plan. taskKind is the tell.
task.ts is the resolver and owns one side effect: seeding lib/focus.ts, without
which the audit hook files events against nothing.
A fourth record kind = sessionPreamble entry + a read + a TOOL_VERBS line
(apps/app/lib/agent-transcript.ts) + a COPY entry (lib/agent-record.ts).
composeClosing() puts a Who we are block before the capabilities in every
preamble; lib/workspace.ts is the only renderer.
- Tiny, enforced by the write path —
MAX_NARRATIVE(320) andMAX_LINEin@crm/db/workspace. It is prompt-cached and precedes every question. - It says what the context is for — fit, competitor, partner, or nothing — and never a pitch, or the model sells our own product back to us.
- No profile still gets the name line, plus do not guess at what we sell.
- The profile dies with its website —
readWorkspaceIdentityreturns it only whilewebsitematches. - Not a
Companyrow — that needs excluding from every list, facet and join. OneWorkspaceProfilekeyed onWORKSPACE_ID.
The pass is a workspace-profile task using web_fetch (no credits), filed only via
write_workspace_profile, queued by WorkspaceService.update on a website change. A
finished attempt stands the sweep down for seven days.
It may read everything, including full email bodies — internal single-tenant tool, and a signature block is the best source of a job title there is. The boundary is egress:
- No customer text in a third-party query. Derived questions only.
- Nothing from a mailbox into
/workspace— different lifetime. - Nothing sensitive logged. Reading is not logging.
skills/data-boundaries.md is the agent's copy. Keep them in step.
agent/sandbox/sandbox.ts: bash, file tools, /workspace, deny-all egress on
the backend factory so it cannot be forgotten per session. Costs nothing —
web_fetch runs in the app runtime, web_search at the provider.
Never give the sandbox DATABASE_URL. CRM access is authored tools. A shell with
credentials and network is exfiltration-shaped; with neither it is a text processor.
agent_builder and agent_runner are declared subagents with independent
instructions, tools and deny-all sandboxes. They inherit nothing from the root. The
root built-in agent copy tool is disabled; these two named specialists are the only
delegation paths for custom agents.
- Creation requires the current
CREATE_AGENTturn. Every builder tool checks the purpose and command type in session auth. A normal builder chat cannot create a draft by prompt alone. - Builder clarification is durable HITL. The specialist calls eve's built-in
ask_questiondirectly; descendant input requests are proxied to the root channel, and the same child turn resumes when the user answers. The authoredtools/ask_question.tsdisable override must stay absent. Builder task output is typed asdraft_readyand carries the immutable version ids only after save. - Empty never means all. A version chooses
SELECTEDorWORKSPACErecord scope. Selected scope requires at least one record tagged in that private conversation; workspace scope is an explicit grant and cannot also list selected records. - Connections are executable permissions. Only
google:gmailandgoogle:calendarare accepted, only when connected, and the runner does not query their synced tables unless the deployed manifest includes that source. - Actions are structured permissions.
crm.activity.createseparately namesNOTE,TASK, or both. Runtime enforcement never infers a grant from the action's prose summary. Every action is ledgered before execution and keyed by eve's call id for replay safety. - CRM events are shared domain contracts.
@crm/db/crm-eventsowns the event vocabulary, record kind, and builder-facing description used by the API, builder, and worker. API writes enqueue a durableagent-eventtask; the agent worker alone matches live triggers and creates runs. Do not duplicate event lists in prompts or feature code. - Versions can have multiple triggers. The manifest stores a
triggersarray and each entry becomes its ownAgentTriggerrow. Deployment enables every trigger on the approved version, so one agent can react to several independent lifecycle moments without polling or duplicate agents. - Deployment is the human approval boundary. Saving produces a private READY version and never deploys it. The review screen shows its triggers, scope, actions, access and exact files. A user's Deploy action pins that immutable version for the team. Scheduled runner sessions use task mode and therefore cannot pause for a per-action approval; the deployed permission and idempotent runtime checks are the boundary.
- Approved instructions are system context. The runner resolves the pinned
version instructions at
session.started, then callsinspect_runfor the manifest and current run state. Every runner tool also checks theteam-agentpurpose and revalidates scope and action permission. - Stopping a run is a row, not a signal.
agents.cancelRunsettles theAgentRuntoCANCELLEDinside one transaction — terminal event, outstandingAgentActionrows, audit entry — and then pokesPOST /internal/crm/cancel-run, which calls eve'scancel({ continuationToken })forrun:<id>. The row is what stops the work: every run tool refuses a run that is notRUNNING, so a cancel that never reaches the agent still means nothing further is written. The poke only stops it spending tokens, and an unsetAGENT_BRIDGE_SECRETcosts exactly that and nothing more. - Cancelling is not undoing. eve keeps side effects that already completed, so a note, task or Slack message the run already made stays. The dialog says so.
- Whoever started it can stop it, plus the creator and workspace admins.
canCancelis computed server-side per run and is what the button reads, so the control and the 403 cannot disagree. turn.cancelledsettles the run too, keyed offrun:in the continuation token, so a cancel from any other path still reachesfinishedAtrather than leaving the run reading Running forever.- No generic execution surface. Both specialists disable shell, file, arbitrary
web and todo built-ins. The runner also disables direct questions; the builder keeps
only
ask_questionfor durable clarification. CRM access exists only through their small authored tool sets. Tool code runs in the trusted app runtime; the sandbox remains isolated and deny-all.
Runner manifests fail closed when either the explicit record-scope mode or an activity type grant is missing. Versions created before these typed permissions were introduced must be revised and deployed again before they can run.
bun run --filter=agent eval runs the eve end-to-end builder eval against the real
HTTP channel. It creates an isolated private conversation, dispatches the builder,
asserts the declared subagent path, verifies a READY side-effect-free manifest, and
cleans up its rows. It skips visibly when the database, bridge secret or model
credential is unavailable.
browser → /eve/v1/* (same origin, session cookie, x-crm-contact header)
→ 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 + record
→ AGENT_URL/eve/v1/* → channels/eve.ts repFromCrm()
→ instructions/task.ts reads attributes.contactId
- The record travels in the token, never in the message.
- Mounted at
/eve/v1/*because that is whereuseEveAgent()looks — nohost, no CORS, no cross-site cookie. - The proxy is an enforcement point, not a passthrough — the agent never sees the session cookie, so if that route did not check, nothing would.
- eve's
jwtHmac()resolves toprincipalType: "service"— wrong for a person, andlib/approval.tsreads exactly those fields to decide whether to pause for a human.repFromCrmmaps the subject to a real user principal (test/channel-auth.spec.ts). AGENT_BRIDGE_SECRETunset skips the auth entry rather than opening it.
lib/agent-record.ts maps a record kind to everything downstream. The panel's own
rules — snapshot loading, composer state, thread capture, scrolling — are in
docs/agent-panel.md. It lives in the API and is not a breach of rule one:
listing history decides nothing.
eve prefixes them with the channel name. channels/crm.ts mints task:<id>;
session.waiting returns crm:task:<id>. Minting the prefix ourselves meant matching
against crm:crm:task:<id> and returning before completeTask — research ran, facts
were written, sessions looked clean, but no task reached finishedAt, so contacts
sat on "Researching" forever. Hidden because the archived event's
data.continuationToken is un-namespaced while channel.continuationToken is not.
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.
eve dev shows every tool call, every result and every token in its interactive
TUI, and it is the agent package's default dev command. The package's Turbo
task is explicitly interactive, so select the agent pane and press Enter to hand
stdin to eve. Type /traces to open the live trace viewer; Ctrl+Z returns input
to Turbo.
dev:headless keeps eve dev --no-ui available when a terminal cannot render
the nested TUI. It starts the same server on the same port with the same routes
and watcher. In that mode hooks/activity.ts narrates the session instead: a
line per tool call with its arguments, a line per result with how long it took,
the finish reason and token spend of each step, and any failure with its code.
- The lines go to stderr, not stdout. The TUI's default log mode is
stderrand it keeps stdout buffered and hidden, so aconsole.loghere would be invisible in the mode it exists to serve. Written to stderr the same lines show under--no-ui, under the TUI, and ineve logs. - Contents print outside production; the shape prints everywhere. Which tool
ran, whether it worked, what it cost — none of that is anybody's data, so it
logs wherever the agent runs. Arguments and replies carry names, addresses and
whatever a rep typed, which is the "nothing sensitive logged" rule above, so
they are gated on
NODE_ENV. In production the durable record is anAgentEventrow, not a log drain. - It is not the audit trail.
hooks/audit.tswrites every event toAgentEventwhatever this prints, and the panel's transcript is read back from there. A change to one is not a change to the other. - A call is timed by remembering it, because the result event does not carry the tool name or a duration. The map of in-flight calls is bounded rather than trusted: a turn that dies between request and result would otherwise leak an entry per call, forever, in a process that stays up for days.
eve logs reads the full record back, but only for an interactive eve dev
— that is the process that writes .eve/logs/. Under --no-ui the pane is the
record, so keep the turbo scrollback rather than going looking for a file that
was never written.
Two consequences of opting into dev:headless, both worth recognising rather
than debugging:
- A headless dev process cannot reconnect. An interactive
eve devreconnects to a local server that is already up;dev:headlessrejects it and exits non-zero.A dev server is already running for this eve agentmeans exactly what it says: use the terminal it is in, or stop it before starting another. - An orphaned agent holds the port. If turbo dies without reaping its child,
nothing on screen says so and every later
dev:headlessfails the same way.lsof -nP -iTCP:2000 -sTCP:LISTENnames the process to kill.
eve dev never fires schedules on their cron cadence. It is one line in
eve's own schedules guide,
and it used to be the single most confusing thing about working on this agent,
because every visible part of the loop worked: the Research button wrote its
AgentTask row, the sheet said Queued, the toast promised the page would
update — and schedules/dispatch.ts, the only thing that turns a row into a
session, was never called. Twenty rows sat with attempts = 0 and AgentEvent
was empty. Nothing was broken and nothing reported a problem, because nothing
ran.
The poke is what makes dev behave like production now, which is most of why it was widened to both lanes — see dispatch on demand. A row written by the API is dispatched immediately whatever the clock is doing, so the schedule is a backstop rather than the only door.
It only does that when AGENT_BRIDGE_SECRET is set, and that variable is
optional: poke() reads it first and returns without sending anything when it is
unset. So an install that has not set it is back at the paragraph above with no
cron behind it either — rows queue, nothing dispatches, and the queue looks
exactly like a slow agent. Set it, or run the dispatch below by hand.
That leaves two cases where the clock's absence bites even with the poke
working, and both look identical to the above: a task the API did not
write — schedule_recheck,
which books its own dueAt weeks out — and anything queued while the agent was
down, since a missed poke is not retried. For those, the dev server mounts a
one-shot route that runs the exact dispatch path production cron uses:
bun run --filter=agent dispatch
# {"scheduleId":"dispatch","sessionIds":["wrun_01KZ…", …]}It drains both lanes, exactly as the cron does: up to VISIBLE_BATCH (60)
brand and portrait rows six at a time, handled in the process with no session
at all, and RESEARCH_BATCH (12) research rows, one session each. So the
sessionIds it prints are the research rows only — a run that resolved forty
logos prints an empty list and was not idle. Either way it spends real credits, a
vendor call per visible row and a model session per research one; that is the
point of it, and the reason it is a command you run rather than a ticker somebody
leaves on. Watch the agent pane; the session ids it returns are also streamable at
GET /eve/v1/session/:id/stream.
eve start on a built app does run the schedule, and so does Vercel, where
each defineSchedule becomes a Cron Job. Dev is the only place the clock is
missing.
eve namespaces a continuation token with the channel's name. channels/crm.ts
mints task:<id>; by the time session.waiting hands it back on the channel
context it is crm:task:<id>. The eve channel's own sessions read back as
eve:<uuid> for the same reason.
This is worth stating because of how it failed, which was silently and
completely. taskToken() used to mint crm:task:<id> itself, so the handler was
matching startsWith("crm:task:") against crm:crm:task:<id>, getting null,
and returning before completeTask. Nothing errored. The research ran, facts
were written, briefs were saved, and the agent pane showed a clean session — but
no task ever reached finishedAt, so every contact sat on "Researching"
forever and the sweep re-queued work that had already been done. Twenty-eight
tasks, zero finished.
Two things made it survive a reading:
- The event data and the channel accessor disagree.
session.waiting'sdata.continuationTokencarries the token as stored — un-namespaced — whilechannel.continuationTokencarries it namespaced. Debugging from the archived event says the token is fine, because from that angle it is. - Nothing downstream depends on settling. The record's status is the only
thing that notices, and "still researching" is indistinguishable from
"researching slowly" until you look at
attemptsin the table.
So taskFromToken keys on the task: marker rather than a fixed prefix, which
reads correctly whoever namespaced it and still settles sessions parked before
the fix. test/crm-token.spec.ts pins all three forms.
The general rule: a channel handler must not assume the token it receives is byte-identical to the one it sent. Parse for your own marker.
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.
The test/e2e scripts are separate and you run them by hand. Three of them cost
money or change something outside the database, so each one is off unless you
switch it on:
| Variable | What it does |
|---|---|
E2E_LIVE_MODEL=1 |
live-run.e2e.ts calls the real model. It spends credits. |
E2E_SLACK_JOIN=1 |
slack-join.e2e.ts joins real Slack channels. It does not leave them. |
E2E_LOAD_COUNT |
How many tasks load.e2e.ts queues. The default is 300. |
Stop the local agent before you run an e2e script against a dev database. A
running agent leases the rows the script seeds and retires exhausted tasks the
script did not create.
taskFromToken keys on the task: marker, not a fixed prefix
(test/crm-token.spec.ts). A channel handler must not assume the token it receives
is byte-identical to the one it sent.