Markdown for agents
This page as plain markdown, ready to paste into an LLM.
Object schemas
Every field of every object, read from the serializers the server runs.
Every object the API returns. A field that is not in these tables is not on the wire.
workspace member lead reply attachment message note task_sequence task meeting event_type event_type_host event_type_form_field slot availability template template_folder asset sequence sequence_step enrollment campaign_tag campaign rep_stat speed_bucket insights_summary campaign_step campaign_insight daily_point
Reading these tables
- Every object has an
objectfield naming its type, so a mixed list is still parseable. string | nullmeans the field is always there and can be null. Optional means the key can be missing, which is how expansions work.- Ids are opaque. The prefix gives the kind:
lead_,rep_,msg_,tsk_,mtg_,note_,tpl_,evt_,seq_,ast_,cmp_,usr_. A workspace id is its slug. - Timestamps are ISO 8601 UTC. Days are
YYYY-MM-DD.
How much to trust a field
Every field in every object schema carries a determinism class. This is the single change that keeps a caller from writing code that looks right and is quietly wrong.
| Class | Meaning |
|---|---|
stable | A real column. The same for every caller, forever. |
derived | Computed from stable columns. The same for every caller. |
key-scoped | Depends on which key asked, because it depends on the key's owner. |
best-effort | A heuristic or a provider-dependent value with documented failure modes. |
upstream | Proxied live from EmailBison. May be stale, capped, or truncated. |
Three fields on lead are key-scoped and it matters. has_open_task, next_due_at, and unread all come from rollups that filter on the viewer. the app:
eq(sql`coalesce(${schema.tasks.assignedToId}, ${schema.tasks.userId})`, opts.viewerId),Two keys owned by two different people get different answers for the same lead, and neither answer is "does this lead have open work". Marking them key-scoped in the field table forces a caller to reason about it. The alternative, silently returning one person's view as if it were the truth, is how a report ends up wrong.
Never returned
These columns are left out of every response, expansion and error message.
| Table | Withheld columns |
|---|---|
clients | id eb_token eb_team_id docuseal_api_key airtable_record_id slack_internal_channel_id slack_client_channel_id gdrive_link cal_com_link no_calling public_booking_enabled feature_crm feature_calling |
users | password_hash password_set_at fathom_api_key google_sub signature twilio_phone_number twilio_phone_sid twilio_numbers crm_enabled inbox_enabled calling_enabled email_notify_enabled onboarded_at install_prompt_dismissed_at deactivated_at |
responses_index | notified_at last_read_at disqualified_by booked_by |
lead_notes | client_id response_id |
tasks | deleted_at email_body linkedin_text email_template_id enrollment_id sequence_step_id company_id opportunity_id |
scheduled_meetings | meet_link google_event_id google_calendar_id google_event_html_link host_notified_at response_id eb_lead_id company_id contact_id opportunity_id internal_notes |
event_types | external_id exclude_from_insights created_by_user_id |
event_type_email_settings | * |
reply_templates | ai_vars.instructions group |
followup_sequences | deleted_at |
followup_enrollments | client_id |
thread_cache | payload updated_at |
reply_attachments | download_url uuid |
api_keys | secret_hash key_id created_by_id revoked_by_id last_used_ip_prefix request_count |
enrichment_events | phone |
The objects
workspace
id is the SLUG, not clients.id. The slug is already the public handle everywhere in this product (it is in every internal URL), it is stable, and it reads well in a caller's code. clients.id is a serial that appears inside signed object ids and never on its own.
synced_at is published under its real name. It is when the sync-eb-workspaces cron last reconciled the workspace against EmailBison, so calling it created_at would invent a meaning for a value that moves every six hours.
counts and sdr_notes are present only when the caller asked for them via ?include=.
| Field | Type | Notes |
|---|---|---|
object | "workspace" | |
id | string | |
name | string | |
domain | string | null | |
logo_url | string | null | |
active | boolean | |
features | Record<string, boolean> | |
granted_scopes | string[] | |
synced_at | string | null | |
counts (optional) | WorkspaceCounts | |
sdr_notes (optional) | string | null |
member
email is users.google_email: the table has no email column, the sign-in identity is the Google address.
role goes through normalizeRole, which returns only admin or user. Seven legacy values still sit in the column (internal_sdr, client_sdr, client_viewer, client_ae, sdr, client, client_sdr_airpay) and all fold to user, so a caller never has to know they existed.
active is the boolean form of deactivated_at. The timestamp itself stays private: when a person was offboarded is nobody's business outside the agency.
assignment is set only on GET.../leads/{lead}/assignees, where the two extra fields describe the assignment rather than the person.
| Field | Type | Notes |
|---|---|---|
object | "member" | |
id | string | |
name | string | null | |
email | string | |
avatar_url | string | null | |
role | "admin" | "user" | |
timezone | string | null | |
active | boolean | |
assignable | boolean | |
created_at | string | null | |
assignment_source (optional) | "auto" | "manual" | |
assigned_at (optional) | string | null |
lead
A lead is a group of reply rows, collapsed to its newest.
responded, unread, has_open_task and next_due_at are resolved for the key's owner, so two keys held by two people report different values for the same lead. responded is team-wide instead.
campaign_id and campaign_name are null far more often than a caller expects. A background job fills them in within 15 minutes, and historical rows stay null with no backfill.
| Field | Type | Notes |
|---|---|---|
object | "lead" | |
id | string | |
workspace | string | |
email | string | |
first_name | string | null | |
last_name | string | null | |
company_name | string | null | |
title | string | null | |
status | LeadStatus | |
classification | string | null | |
state | LeadState | |
subject | string | null | |
body_preview | string | null | |
latest_reply_id | string | |
last_message_at | string | null | |
last_message_direction | string | |
last_action_at | string | null | |
booked_at | string | null | |
disqualified_at | string | null | |
meeting_outcome | string | null | |
meeting_outcome_at | string | null | |
responded | boolean | |
unread | boolean | |
campaign_id | string | null | |
campaign_name | string | null | |
assignee_ids | string[] | |
has_open_task | boolean | |
next_due_at | string | null | |
indexed_at | string | null | |
assignees (optional) | SerializedMember[] | Expansions, each present only when requested. |
campaign (optional) | SerializedCampaign | null | |
open_tasks (optional) | SerializedTask[] | |
notes (optional) | SerializedNote[] | |
meetings (optional) | SerializedMeeting[] | |
sdr_notes (optional) | string | null |
reply
One indexed inbound email, the atomic unit responses_index is keyed on. The full conversation lives at /messages.
status is the raw per-reply last_action, lowercased the same way as a lead's but nullable here, because at the reply level "no tag" is genuinely absent rather than the untagged lifecycle state.
| Field | Type | Notes |
|---|---|---|
object | "reply" | |
id | string | |
lead_id | string | |
workspace | string | |
email | string | |
subject | string | null | |
body_preview | string | null | |
status | string | null | |
classification | string | null | |
direction | string | |
received_at | string | null | |
campaign_id | string | null | |
campaign_name | string | null |
attachment
A filename and an opaque id. That is the whole object.
ReplyAttachment is { id, uuid?, file_name, download_url }. There is no size and no content type in EmailBison's payload, so neither is invented. download_url is withheld: it is a signed EmailBison link whose signature covers the exact path, which makes it a bearer capability, and handing one to a third-party integration gives that integration the file. Downloading needs a proxied endpoint with its own auth, which is v3.
| Field | Type | Notes |
|---|---|---|
object | "attachment" | |
id | string | |
filename | string |
message
One message in a lead's merged timeline.
kind comes from the sign of the raw id. buildLeadThreadMessages synthesizes a campaign send as -e.id so it cannot collide with a real reply id during dedupe, which makes the sign the only marker of provenance. The negative integer itself is never surfaced.
direction and sent_at are delegated to thread-direction.ts and must not be reimplemented. That module exists to kill two live bugs: matching the sender against the stored lead email mislabels a lead who replies from a different address, and EmailBison's date_received on an OUTGOING message is a delivery stamp 6 to 23 minutes late, which sorted a quick reply before the message it answered.
body_text is derived. There is no plaintext column anywhere in the read path: EbThreadMessage has no text_body, so this is htmlToText(html_body), which collapses tags and is explicitly not a sanitizer. Good enough for a model to read, wrong for rendering.
to_email is usually null on a real reply. It comes from primary_to_email_address, which only the synthesized campaign sends carry.
| Field | Type | Notes |
|---|---|---|
object | "message" | |
id | string | |
lead_id | string | |
kind | "reply" | "campaign_send" | |
direction | "inbound" | "outbound" | |
reply_id | string | null | |
subject | string | null | |
from_name | string | null | |
from_email | string | null | |
to_email | string | null | |
body_html | string | null | |
body_text | string | null | |
sent_at | string | null | |
attachments | SerializedAttachment[] |
note
| Field | Type | Notes |
|---|---|---|
object | "note" | |
id | string | |
workspace | string | |
lead_id | string | |
reply_id | string | null | |
body | string | null | |
author_id | string | null | |
created_at | string | null | |
updated_at | string | null |
task_sequence
| Field | Type | Notes |
|---|---|---|
id | string | |
name | string | null | |
step_index | number | null |
task
Four columns this object claims do not exist on tasks, and each absence is load-bearing.
There is no client_id. The tenant is derived through response_id -> responses_index.client_id, and every query adds isNotNull(response_id) because response_id is nullable so a task can hang off a dead CRM company_id instead. Those rows have no lead, no reply and no derivable workspace, so they are invisible to the API. That is also why reply_id here is non-null.
There is no status. Open is completed_at IS NULL AND deleted_at IS NULL. Soft-deleted rows are filtered out of the query and never surface as a status.
There is no title. The only source is followup_sequence_steps.title, so a manually created task is always null here. The field stays in the shape so a caller does not have to branch, and writes reject it outright rather than accepting it and dropping it.
assignee_id is coalesce(assigned_to_id, user_id).
sequence is the only sanctioned view of the materialization internals. The raw sequence_id, enrollment_id, sequence_step_id, email_template_id, email_body and linkedin_text are all withheld.
| Field | Type | Notes |
|---|---|---|
object | "task" | |
id | string | |
workspace | string | |
lead_id | string | |
reply_id | string | |
title | string | null | |
notes | string | null | |
task_type | string | null | |
due_date | string | null | |
due_time | string | null | |
status | "open" | "completed" | |
creator_id | string | |
assignee_id | string | |
completed_at | string | null | |
created_at | string | null | |
sequence | SerializedTaskSequence | null |
meeting
host_id is assigned_ae_id, the round-robin-picked host whose calendar holds the event. booked_by_id is the operator who ran the booking form, which for a calendar booking is usually not the host. Keeping them apart matters for attribution: insights credit bookings to booked_by_id.
custom_fields keys are event_type_form_fields.field_key values.
Times can lag a host-side reschedule by up to 15 minutes, because the API read path deliberately skips syncUpcomingMeetingTimes: that helper makes a live Google or Microsoft call per row and writes corrected times back, which is a per-row cost on a per-request budget and burns the host's OAuth quota on a read.
meet_link and google_event_html_link are never returned in v1. Both are join capability URLs: anyone holding one can walk into the client's sales call. internal_notes is passed only on the single-object read and only for a key holding meetings:write; its schema comment is the most explicit internal-only marker in the whole database.
| Field | Type | Notes |
|---|---|---|
object | "meeting" | |
id | string | |
workspace | string | |
lead_id | string | null | |
event_type_id | string | null | |
source | string | |
booked_at | string | null | |
booked_by_id | string | null | |
host_id | string | null | |
starts_at | string | null | |
ends_at | string | null | |
timezone | string | null | |
invitee | { name: string | null; email: string | null; phone: string | null } | |
email | string | null | |
name | string | null | |
company_name | string | null | |
domain | string | null | |
guests | Array<{ name: string; email: string }> | |
custom_fields | Record<string, string | boolean> | |
utm_params | Record<string, string> | |
channel | string | null | |
confirmation_status | string | |
location | { type: string | null; phone: string | null } | |
outcome | string | null | |
outcome_at | string | null | |
outcome_by_id | string | null | |
locked | boolean | |
created_at | string | null | |
internal_notes (optional) | string | null |
event_type
default_field_config is stored as {} when the built-in defaults apply, so this resolves it through resolveDefaultFieldConfig and always returns the concrete map. A caller never has to know the empty-object convention.
public_url is null when no public origin is configured, rather than a localhost link a caller would send to a prospect.
external_id is withheld: it is a provider-side event-type id that is only useful paired with the workspace's connector credential, so publishing it moves half of a credential pair outside the building. exclude_from_insights is an internal reporting flag. The whole of event_type_email_settings stays private: the subject and body templates are internal copy and host_notify_user_ids is internal routing.
| Field | Type | Notes |
|---|---|---|
object | "event_type" | |
id | string | |
workspace | string | |
name | string | |
slug | string | |
description | string | null | |
event_name_template | string | null | |
duration_minutes | number | |
buffer_before_minutes | number | |
buffer_after_minutes | number | |
scheduling_type | string | |
public | boolean | |
public_url | string | null | |
public_booking_window_days | number | |
minimum_notice_minutes | number | |
business_emails_only | boolean | |
redirect_url | string | null | |
workspace_wide | boolean | |
active | boolean | |
requires_confirmation | boolean | |
type | string | null | |
phone | string | null | |
description | string | null | |
order_index | number | |
use_event_type_availability | boolean | |
default_field_config | DefaultFieldConfig | |
booking_provider | string | |
created_by_id | string | null | |
created_at | string | null | |
updated_at | string | null | |
hosts (optional) | SerializedEventTypeHost[] | |
form_fields (optional) | SerializedEventTypeFormField[] |
event_type_host
member_id is the opaque form of event_type_hosts.user_id.
| Field | Type | Notes |
|---|---|---|
object | "event_type_host" | |
id | string | |
event_type_id | string | |
member_id | string | |
weight | number | |
priority | number | |
is_host | boolean | |
active | boolean |
event_type_form_field
| Field | Type | Notes |
|---|---|---|
object | "event_type_form_field" | |
id | string | |
event_type_id | string | |
field_key | string | |
label | string | |
type | string | |
options | string[] | |
required | boolean | |
order_index | number | |
active | boolean |
slot
A start and an end, and nothing else, deliberately. No host identity on a slot, so a caller cannot enumerate who on the team is free when, which is private calendar information about a person rather than a fact about the workspace.
| Field | Type | Notes |
|---|---|---|
object | "slot" | |
start | string | null | |
end | string | null |
availability
reason carries a short cause when slots is empty and the cause is knowable (outside_booking_window, no_active_hosts, no_calendar_connected), so a caller can tell "nothing free" from "misconfigured".
Raw calendar busy blocks are never returned. They are the host's private calendar, and the free slots are the only part that is the caller's business.
| Field | Type | Notes |
|---|---|---|
object | "availability" | |
event_type_id | string | |
duration_minutes | number | |
timezone | string | null | |
window | { from: string | null; to: string | null } | |
slots | SerializedSlot[] | |
reason | string | null |
template
body is the RAW template, unresolved {{tokens}} and all. Never send it as an email: POST.../templates/{id}/render exists for exactly that, and it is the single most likely way an agent damages a client.
scope exists because of a real cross-tenant quirk. visibleWhere in the templates route returns every row with visibility = "global" from ANY workspace, with no client_id bound on that branch, so a list under one workspace legitimately contains rows owned by another. Rather than hide that, scope names it and a global template reports workspace: null, which keeps the nested-path invariant from being quietly broken.
visibility null is reported as workspace, matching how every read path in the app already treats it, so a caller never has to handle a third state that means the same as the second.
ai_variables exposes name and read_website only. instructions is withheld: it is free-text model instructions, which is internal copy and a prompt-injection surface if a caller can read and echo it. reply_templates.group is withheld as legacy bucketing superseded by folder_id.
| Field | Type | Notes |
|---|---|---|
object | "template" | |
id | string | |
workspace | string | null | |
scope | "workspace" | "global" | |
name | string | |
subject | string | null | |
body | string | |
is_html | boolean | |
visibility | string | |
folder_id | string | null | |
order_index | number | |
variables | string[] | |
ai_variables | Array<{ name: string; read_website: boolean }> | |
created_by_id | string | null | |
created_at | string | null |
template_folder
| Field | Type | Notes |
|---|---|---|
object | "template_folder" | |
id | string | |
workspace | string | |
name | string | |
sort_order | number | |
template_count | number | |
created_at | string | null |
asset
url carries the link for loom, doc and link; body carries the snippet for text and is null otherwise.
client_assets.visibility has only TWO values, private and workspace. There is no global, unlike reply_templates.visibility and followup_sequences.visibility, which have three. An agent that learned visibility on templates and sends global here gets a 422, and GET /v1/capabilities calls the difference out.
Rows with visibility: "private" are filtered to the key's owner in the query, not here.
| Field | Type | Notes |
|---|---|---|
object | "asset" | |
id | string | |
workspace | string | |
kind | string | |
title | string | |
url | string | null | |
body | string | null | |
visibility | string | |
order_index | number | |
created_by_id | string | null | |
created_at | string | null |
sequence
Both forms of the sending window are returned. send_days is readable and send_days_mask is the value a write would have to send back, and these pages say the basis is UTC: a sequence set to weekdays fires on UTC weekdays, not the rep's.
followup_sequences.deleted_at is withheld and soft-deleted rows are filtered out of the query, so a caller never sees a tombstone.
| Field | Type | Notes |
|---|---|---|
object | "sequence" | |
id | string | |
workspace | string | |
name | string | |
active | boolean | |
visibility | string | |
send_days | string[] | |
send_days_mask | number | |
created_by_id | string | null | |
created_at | string | null | |
steps (optional) | SerializedSequenceStep[] |
sequence_step
wait_for_previous is the column wait_for_prev, spelled out on the wire.
| Field | Type | Notes |
|---|---|---|
object | "sequence_step" | |
id | string | |
order_index | number | |
type | string | |
title | string | null | |
delay_value | number | |
delay_unit | string | |
wait_for_previous | boolean | |
template_id | string | null | |
email_body | string | null | |
linkedin_text | string | null |
enrollment
followup_enrollments.client_id is notNull with NO foreign key, so the read path filters it and ALSO joins responses_index through response_id, which does carry a cascading foreign key. The raw column is withheld.
| Field | Type | Notes |
|---|---|---|
object | "enrollment" | |
id | string | |
workspace | string | |
sequence_id | string | null | |
lead_id | string | |
reply_id | string | |
status | string | |
next_step_index | number | |
enrolled_by_id | string | null | |
created_at | string | null |
campaign_tag
These ids are the match key for assignment_rules.tag_ids and sdr_note_sets.tag_ids, both matched by Postgres array overlap.
| Field | Type | Notes |
|---|---|---|
object | "campaign_tag" | |
id | string | |
workspace | string | |
name | string | |
campaign_count | number |
campaign
Proxied live from EmailBison with the per-workspace token from clients.eb_token. That token is stored in PLAIN TEXT, which is exactly why this is a hand-written five-field mapper and never a spread of an upstream response: no field EmailBison invents tomorrow can arrive on the wire by accident, and the token cannot ride along in a response, an error or a log.
The tags here are the inline { id, name } form, not full campaign_tag objects, so a campaign row does not carry a count nobody asked for.
| Field | Type | Notes |
|---|---|---|
object | "campaign" | |
id | string | |
workspace | string | |
name | string | |
status | string | |
tags | Array<{ id: string; name: string }> |
rep_stat
RepStat.image is dropped: an avatar URL belongs on a member, and repeating it in an aggregate invites a caller to build a directory out of a report.
The booking-rate inputs are approximate and these pages say so. Interested is attributed through action_log and booked through scheduled_meetings.booked_by_id, and those two can disagree for the same rep.
| Field | Type | Notes |
|---|---|---|
member_id | string | |
name | string | null | |
meetings_booked | number | |
showed_meetings | number | |
meetings_with_outcome | number | |
show_rate | number | |
interested_leads | number | |
booked_from_interested | number | |
booking_rate | number | |
first_response_avg_sec | number | |
first_response_count | number | |
followup_avg_sec | number | |
followup_count | number |
speed_bucket
| Field | Type | Notes |
|---|---|---|
label | string | |
n | number | |
booked | number | |
showed | number | |
booking_rate | number | |
show_rate | number |
insights_summary
EVERY RATE IS A RATIO IN [0,1], never a percentage. A caller that multiplies by 100 twice is a documentation failure, and the way to avoid it is to never ship a percent.
meetingsTimeline and bookingTimeline are NOT included. Both are fixed "last 12 ISO weeks" series that ignore the requested window, so returning them next to a windowed funnel would put two different time ranges in one object. GET /insights/daily is the windowed series.
speed_to_lead is deploy-forward only: lead_response_times rows exist from the day the feature shipped, so a window reaching earlier under-counts.
generated_at is passed in as a JS Date. Never now() in SQL: every timestamp column here is timestamp without time zone read back as UTC, so a SQL now() writes local wall-clock and lands hours off.
| Field | Type | Notes |
|---|---|---|
object | "insights_summary" | |
workspace | string | |
window | { from: string | null; to: string | null } | |
all_responses | number | |
positive_responses | number | |
booked_meetings | number | |
showed_meetings | number | |
total_meetings_booked | number | |
meetings_with_outcome | number | |
show_rate | number | |
total_interested_leads | number | |
booked_interested_leads | number | |
booking_rate | number | |
first_response_avg_sec | number | |
first_response_count | number | |
followup_avg_sec | number | |
followup_count | number | |
per_rep | SerializedRepStat[] | |
speed_buckets | SerializedSpeedBucket[] | |
generated_at | string | null |
campaign_step
step_id is EbSequenceStepStat.sequence_step_id, a CADENCE step id, and it is the one raw upstream integer in the whole API. It is published because a caller comparing two runs needs to line the rows up, and it is not sensitive: it names a step inside a campaign the caller can already read. It is NOT durable, though. Removing a variant in EmailBison resets per-variant ids and breaks historical metrics, which is a documented production hazard.
| Field | Type | Notes |
|---|---|---|
object | "campaign_step" | |
step_id | number | |
label | string | |
subject | string | |
sent | number | |
contacts | number | |
replies | number | |
interested | number |
campaign_insight
The array is steps, not variants, on purpose. They come from EmailBison sequence steps, and the UI's "Variant A" labels are preserved, but calling the array variants would invite every consumer to read it as A/B test output. EmailBison stamps a reply with its campaign and not with the variant that produced the sent email, so there is no per-step booked or showed and none is invented.
There is no unique_opens. Every campaign ships open_tracking: false, so the number does not exist and a null field would only invite someone to chart it.
bounced here is a reporting number, not the deliverability gate the daily diagnosis runs on.
clientSlug and clientName from CampaignStat are dropped: the workspace is already in the request path, and repeating it per row is how a cross-client report gets built by accident.
| Field | Type | Notes |
|---|---|---|
object | "campaign_insight" | |
campaign_id | string | |
campaign_name | string | |
status | string | |
emails_sent | number | |
contacts | number | |
replies | number | |
interested | number | |
bounced | number | |
booked | number | |
showed | number | |
tags | Array<{ id: string; name: string }> | |
steps | SerializedCampaignStep[] |
daily_point
date is a UTC calendar day and the series is DENSE: a day with no activity comes back as zeros rather than being omitted, so a caller can chart it without gap-filling.
| Field | Type | Notes |
|---|---|---|
object | "daily_point" | |
date | string | |
emails_sent | number | |
responses | number | |
positive_responses | number | |
booked | number |