Documentation · Integration guide
Connect your platform to a CrewTAC agent.
You do not need to change how your systems work. Every external system sits behind an adapter, so integration is a matter of choosing which direction data flows — not rebuilding anything you already have.
Three patterns
Choose which direction data flows.
Most integrations use two of these. Few need all three.
- 1
You call us
Your system sends work in and gets a result back. Synchronous, simplest to build, and where every integration starts.
your form, widget or backend jobPOST /v1/…
Use when: You have a website form, a chat widget, a batch importer, or any place a business event already happens in your code.
- 2
We call you
When an agent finishes, we push a signed event to an endpoint you own. Your systems stay in sync without polling.
signed eventyour app, queue or warehouse
Use when: You want results in your data warehouse, your notification system, or a queue your own workers consume.
- 3
We act in your systems
The agent reads from and writes to your CRM, help desk, email or calendar through an adapter. Every write is previewable and idempotent, and high-impact actions wait for a human.
agentyour CRM, help desk, calendar
Use when: The agent should do something — create the CRM contact, open the ticket, book the call — rather than just report what it decided.
Which do you need?
| If you want to… | Patterns |
|---|---|
| Score leads arriving on your website | 1 |
| Push qualified leads into your warehouse | 1 + 2 |
| Have the agent create the record in your CRM | 1 + 3 |
| Answer support questions from your documentation | 1 |
| Open tickets in your existing help desk | 1 + 3 |
| Keep your help desk and the agent in sync both ways | 1 + 2 + 3 |
Onboarding path
Six steps, each independently useful.
You can stop at any point and still have something working.
Connect and call once
Get a key, submit one record, read the response. Everything runs against mock connectors, so nothing touches your production systems.
Send real inputs, keep mock outputs
The agent scores and decides; the write still goes to a mock. Compare its decisions against what your team would have done. This is where you calibrate, and it costs nothing to get wrong.
Connect one real system
Usually the CRM or help desk. Use preview_action to see exactly what would be written before enabling writes.
Turn on outbound webhooks
Your systems now receive results as they happen.
Tune the human-in-the-loop gates
Decide what the agent may do unattended. Defaults are conservative: anything reaching a customer waits for a person.
Go live
Work through the pre-launch checklist, starting with a subset of traffic and a kill switch you control.
Timing
Steps 1–2 are usually days. Step 3 is the real work, and its size depends entirely on your system's API — not on ours.
Authentication
One header, scoped per key.
Keys are tenant-scoped. Only a SHA-256 hash is stored, and the plaintext key is shown exactly once at creation — put it in your secret manager immediately.
curl https://aiapp.crewtac.com/v1/leads \
-H "X-API-Key: $CREWTAC_API_KEY"Scopes
Request the narrowest set that does the job; a separate read-only key for reporting is good practice. A request missing a scope returns 403 permission_denied naming the scope it needed.
| Scope | Grants |
|---|---|
leads:read | Read leads, qualifications, actions and their audit trail |
leads:write | Submit leads and run the qualification workflow |
leads:approve | Approve, reject or override a recommended action |
support:read | Read conversations, messages and citations |
support:write | Submit questions and record feedback |
support:escalate | Open a ticket explicitly |
knowledge:read | List knowledge documents |
knowledge:write | Ingest and version knowledge documents |
documents:read | Read documents, extractions and the review queue |
documents:write | Upload documents for processing |
documents:review | Approve, correct or reject an extraction |
documents:export | Send an approved result to a destination system |
tasks:read | Read tasks, workflows, tools and the approval queue |
tasks:write | Start and resume workflows |
tasks:approve | Approve or reject a paused step |
tools:execute | Execute a single tool for real, rather than previewing |
crm:read | Ask CRM questions and read accounts |
crm:write | Preview and execute CRM changes |
comms:send | Tools that email or invite someone outside your organisation |
tickets:write | Tools that create or update tickets |
webhooks:receive | Post inbound events to CrewTAC |
webhooks:publish | Read and dispatch queued outbound events (POST /v1/webhooks/dispatch) |
audit:read | Read the tenant-wide audit log |
Retries and errors
Make every write safe to repeat.
Send Idempotency-Key on every write. Without it, a network timeout leaves you unable to retry safely — you cannot tell whether the first attempt landed.
curl -X POST https://aiapp.crewtac.com/v1/leads \
-H "X-API-Key: $CREWTAC_API_KEY" \
-H "Idempotency-Key: 6f1c2a90-3e7d-4b1a-9c22-8f0e1d4a55b3" \
-H "Content-Type: application/json" \
-d '{ "name": "...", "email": "...", "problem": "...",
"consent_status": "granted" }'| Situation | Response |
|---|---|
| First call | 201 with the result |
| Retry — same key and same body | 200, the original response, header Idempotent-Replay: true. Nothing re-executes. |
| Same key, different body | 409 idempotency_conflict |
The 409 is deliberate. Silently returning the old response would hide a bug in your code where one key is being reused for two different records. Idempotency also runs inside the connector layer, so even a request arriving without a key updates an existing CRM contact rather than creating a second one.
The error envelope
Branch on code, never on message — message text is written for humans and may be reworded without notice.
{
"error": {
"code": "validation_error",
"message": "Human-readable explanation.",
"retryable": false,
"details": {},
"correlation_id": "corr_9f2c1a0b4e7d"
}
}| Code | HTTP | Retry | Usual cause |
|---|---|---|---|
validation_error | 422 | No | Malformed or missing field |
authentication_error | 401 | No | Missing, invalid or revoked key |
permission_denied | 403 | No | Key lacks the required scope |
not_found | 404 | No | Unknown ID, or an ID from another tenant |
idempotency_conflict | 409 | No | Key reused with a different body |
policy_violation | 422 | No | Action blocked by a guardrail |
not_implemented | 501 | No | Endpoint belongs to an agent not yet built |
rate_limited | 429 | Yes | Throttled |
connector_error | 502 | Yes | A downstream system failed |
llm_provider_error | 502 | Yes | Model provider unavailable |
internal_error | 500 | Yes | Unexpected failure on our side |
Retry retryable failures with exponential backoff and jitter, reusing the same Idempotency-Key. Never retry a non-retryable error — the same request will fail identically. Every response carries X-Correlation-ID; send your own to trace a request across both systems, and include it in any support conversation.
On rate limiting
The rate_limited code and the error contract are in place, but throttling is not yet enforced — limits are set per deployment during onboarding. Build the 429 path into your client now, so enabling limits later is a configuration change on our side rather than a code change on yours.
Agent 01 · Sales Qualification
Submit a lead, get a scored and routed opportunity.
With the CRM record already created and the recommended next action waiting for whatever approval you require.
- POST
/v1/leadsSubmit a lead; runs the full workflow - GET
/v1/leads/{id}Lead with its qualification and actions - GET
/v1/leads/{id}/auditEvery decision, tool call and approval - POST
/v1/leads/{id}/approveApprove, reject or override the action - POST
/v1/webhooks/crmReceive CRM change events (signed)
{
"name": "Sofia Alvarez",
"email": "sofia@brightpath.example",
"company": "Brightpath SaaS",
"role": "VP RevOps",
"company_size": "51-200",
"industry": "SaaS",
"source": "demo_request",
"consent_status": "granted",
"problem": "Inbound leads sit in a queue for two days...",
"crm_context": { "existing_account_id": "0015g00000XyZ" }
}{
"lead": {
"id": "lead_f546425682d44ccc94a24880",
"status": "qualified",
"external_ids": { "crm_contact_id": "cont_7a1e0b93c4d24f18" }
},
"qualification": {
"score": 82,
"fit": "high",
"intent": "buying",
"confidence": 0.648,
"reason_codes": [
{ "code": "COMPANY_SIZE", "label": "Company size 51-200.", "points": 12 },
{ "code": "TARGET_INDUSTRY", "label": "'SaaS' is a target industry.", "points": 6 },
{ "code": "SENIOR_ROLE", "label": "Decision-maker role.", "points": 9 },
{ "code": "INTENT", "label": "Classified intent: buying.", "points": 18 }
],
"rules_version": "v1",
"prompt_version": "sales.lead_classify@v1",
"model_version": "claude-opus-5"
},
"actions": [
{
"action_type": "book_discovery_call",
"status": "pending_approval",
"requires_approval": true,
"approval_reason": "High-impact outbound action requires human approval."
}
],
"duplicate": { "found": false, "source": "none", "confidence": "none" }
}Reason codes sum exactly to the score
A breakdown that does not add up is not an explanation, so the platform guarantees it — including any cap or clamp applied. You can render this table straight to a salesperson.
consent_status is not decoration
Anything other than granted puts the lead on hold, caps its score so it never surfaces high in a work queue, and prevents any outreach being created — regardless of how attractive the company looks. Send the real value.
Actions and the approval gate
An action with pending_approval is waiting for a person. Nothing was sent.
| action_type | Reaches the prospect | Default |
|---|---|---|
book_discovery_call | Yes | Waits for approval |
send_nurture_sequence | Yes | Waits for approval |
request_more_information | Yes | Waits for approval |
assign_to_sales_rep | No | Runs; escalates if confidence is low |
hold_no_consent | No | Runs |
disqualify | No | Runs |
Approving accepts an override_action_type that replaces the agent’s recommendation. The original is preserved in the audit trail, so the agent’s judgement stays visible and reviewable.
Agent 02 · Customer Support
Answers only from documentation you approved.
With citations naming the exact version used — or an escalation, with the reason recorded.
- POST
/v1/support/messagesSubmit a question; runs the full workflow - GET
/v1/support/conversations/{id}Messages, citations and ticket - POST
/v1/knowledge/documentsIngest an approved document - GET
/v1/knowledge/documentsList approved documents - POST
/v1/ticketsEscalate a conversation explicitly - POST
/v1/feedbackRecord answer-quality feedback - POST
/v1/webhooks/ticketsReceive ticket events (signed)
Ingest your documentation first
With an empty knowledge base the agent escalates everything — correctly, but uselessly.
POST /v1/knowledge/documents
{
"title": "Billing and Payments",
"content": "# Billing and Payments\n\n## Refunds\nRefunds are...",
"source_uri": "https://yourdocs.example/kb/billing",
"metadata": { "product": "northbeam", "audience": "customer" }
}- Markdown headings become the citation path — a passage under ## Refunds cites as “Billing and Payments v1 — Refunds”.
- Re-posting the same title creates version 2 and supersedes version 1. Superseded versions are retained, so an answer given last month still names the version it used.
- Re-posting identical content is a no-op, so a nightly sync will not churn version numbers or invalidate stored citations.
An answered question
{
"reply": {
"status": "delivered",
"text": "Refunds are available within 14 days of a charge...",
"confidence": 0.726,
"retrieval_count": 3,
"top_score": 0.31,
"source_refs": [
{
"document_title": "Billing and Payments",
"document_version": 1,
"heading_path": "Billing and Payments > Refunds",
"citation": "Billing and Payments v1 - Refunds",
"score": 0.31
}
],
"escalated": false,
"prompt_version": "support.grounded_answer@v1"
},
"ticket": null,
"escalated": false
}source_refs names the exact document version used. Show it to the customer or keep it for supervisor review — but store it either way, because it is what makes the answer checkable later. An escalation carries no citations: nothing was asserted, so there is nothing to cite.
Escalation codes
| Code | Meaning | What to do |
|---|---|---|
NO_KNOWLEDGE | Nothing retrieved | Content gap — publish the answer |
WEAK_RETRIEVAL | Best passage below the relevance floor | Content gap, or thresholds need calibrating |
NOT_ANSWERABLE | Passages found but they do not cover it | Content gap |
INVALID_CITATION | Cited a passage never supplied | Report it — should not happen |
UNGROUNDED_ANSWER | Claimed support but cited nothing | Report it |
LOW_CONFIDENCE | Below your confidence floor | Expected; tune the floor if frequent |
RESTRICTED_TOPIC | Medical, legal, financial or tax advice | Working as intended |
SENSITIVE_CATEGORY | Data privacy or cancellation | Working as intended |
HUMAN_REQUIRED | Complaint, security, legal threat or distress | Working as intended |
INJECTION_ATTEMPT | Instructions aimed at the AI | Review the conversation |
NEEDS_CLARIFICATION | Too ambiguous — the agent asked back | Not an escalation; no ticket |
Do not lower the threshold to reduce escalations
A rising share of NO_KNOWLEDGE and NOT_ANSWERABLE is a documentation problem, not a model problem. Lowering the floor converts a visible gap into an invisible one — ungrounded answers, delivered confidently. Publish the missing content instead.
Agent 03 · Document Processing
Extraction with the confidence and the verdict shown.
Every field carries the text it was read from, and anything that touches money reaches a person first.
- POST
/v1/documentsUpload a document; runs the full workflow - GET
/v1/documents/{id}Extraction, field confidence and review state - GET
/v1/documents/{id}/contentDownload the original, for a reviewer - GET
/v1/documents/review-queueOpen review tasks, oldest first - GET
/v1/documents/schemasDocument types this deployment extracts - POST
/v1/documents/{id}/reviewApprove, correct or reject an extraction - POST
/v1/documents/{id}/exportSend the approved result downstream - POST
/v1/webhooks/documentProcess a document from object storage (signed)
Submitting a document
multipart/form-data, not JSON — the file is the body. Send document_type when you already know it; leave it out and the agent classifies the document itself.
curl -X POST https://aiapp.crewtac.com/v1/documents \
-H "X-API-Key: $CREWTAC_API_KEY" \
-H "Idempotency-Key: 7c1f2b40-9a55-4e01-b7d3-2c8e5f1a9042" \
-F "file=@invoice-2041.pdf" \
-F "document_type=invoice"The response
One call runs the whole workflow, so you get the finished result rather than a job id.
{
"document": {
"id": "doc_8c1c5ca9ed944c20b26a",
"status": "needs_review",
"document_type": "invoice",
"classification_confidence": 0.94,
"text_method": "pdf_text_layer",
"checksum": "3f9a..."
},
"extraction": {
"overall_confidence": 0.91,
"schema_id": "invoice",
"schema_version": "v1",
"rules_version": "v1",
"prompt_version": "document.extract@v1",
"document_errors": []
},
"fields": [
{
"field_name": "total_amount",
"value": 1440.0,
"raw_value": "1,440.00",
"confidence": 0.93,
"validation_status": "valid",
"evidence": "Total Due: 1,440.00",
"high_risk": true
}
],
"review_task": {
"reason": "High-risk fields always require human confirmation.",
"reason_codes": ["HIGH_RISK_FIELD"],
"fields_to_check": ["subtotal", "tax_amount", "total_amount"]
}
}Read value for the normalised value and raw_value for the text as printed. evidence is the line it was read from — show it to your reviewers, because it is the difference between checking a value in seconds and hunting for it.
| Status | Meaning |
|---|---|
processed | Validated, nothing high risk, ready to export |
needs_review | A person must look at it; the review task says why |
approved | A reviewer approved it; it can be exported |
rejected | Refused at the safety gate, or rejected by a reviewer |
failed | Text could not be extracted; rejection_reason says why |
Document types you define
A type is a configuration file, not a development ticket. You decide which fields exist, what makes a value valid, what the arithmetic should come to, and which fields always need a person.
id: invoice
version: v1
fields:
- name: total_amount
type: number
required: true
high_risk: true # always reviewed, whatever the confidence
minimum: 0
aliases: ["Total Due", "Amount Due", "Balance Due"]
cross_field_rules:
- rule: sum_equals
fields: [subtotal, tax_amount]
target: total_amount
message: "Subtotal plus tax must equal the total."aliases are the labels a real document prints — an invoice says “VAT”, not tax_amount. Every result records the schema, prompt, rules and model version that produced it, so an extraction from last quarter can still be read against the definition in force at the time.
Why a document reaches the review queue
| Code | Meaning | What to do |
|---|---|---|
HIGH_RISK_FIELD | A field your schema marks as always-review | Working as intended — a confident wrong total is still a wrong payment |
VALIDATION_FAILED | A value broke a format, range or list constraint | Check the value against the page |
CROSS_FIELD_RULE_FAILED | The totals or the dates do not reconcile | The document is likely wrong, not the reading |
REQUIRED_FIELD_MISSING | A required field was not on the document | Check whether it is genuinely absent |
UNKNOWN_DOCUMENT_TYPE | No registered type matched | Add a schema, or route it elsewhere |
LOW_CLASSIFICATION_CONFIDENCE | It may have been read against the wrong schema | Confirm the type before trusting the fields |
LOW_FIELD_CONFIDENCE | A specific value was hard to read | Expected on poor scans; tune the floor if frequent |
LOW_DOCUMENT_CONFIDENCE | The document as a whole was hard to read | Usually a scan-quality problem at source |
OCR_SOURCE | The text is a transcription, not the document's own | Working as intended |
EXTRACTION_UNAVAILABLE | The model could not be reached | Report it — nothing was read |
A confident wrong total is still a wrong payment
HIGH_RISK_FIELD fires regardless of confidence. On an invoice that is the subtotal, the tax and the total by default. The model’s certainty about its own reading is not evidence about your money, and this is the one rule most worth leaving alone.
What your system receives
Only an approved document exports. Your fields are under data; everything else is provenance. Dates arrive as ISO-8601 and money as numbers with the presentation stripped.
{
"source_document_id": "doc_8c1c5ca9ed944c20b26a",
"document_type": "invoice",
"schema_version": "invoice@v1",
"filename": "invoice-2041.pdf",
"checksum": "3f9a...",
"data": {
"invoice_number": "INV-2041",
"total_amount": 1440.0,
"issue_date": "2026-08-14",
"currency": "GBP"
}
}You do not need to de-duplicate before sending
A document is identified by the SHA-256 of its bytes. The same file uploaded twice, under any names, is one document with one extraction and one downstream write. Export is idempotent on the document, so a retry updates the same record rather than creating a second.
Agent 04 · Operations Workflow
The model picks the process. It never picks the steps.
Those come from a versioned definition you wrote and can read.
- POST
/v1/tasksStart a workflow from a request - GET
/v1/tasks/{id}State, plan, execution trace and open approval - POST
/v1/tasks/{id}/approveApprove or reject a paused step - POST
/v1/tasks/{id}/resumeContinue from where it stopped - GET
/v1/tasks/approvalsSteps waiting for a human - GET
/v1/workflowsRegistered, versioned workflow definitions - GET
/v1/toolsEvery action this deployment can take - POST
/v1/tools/{tool}/executeOne controlled tool call; previews by default - POST
/v1/webhooks/eventsExternal event that starts a workflow (signed)
Starting a workflow
request_text is what a person wrote. context is the structured data the steps bind their arguments from — keep them separate, because a tool argument is never interpolated from raw request prose. Set workflow_id to skip classification when you already know the process.
{
"request_text": "We have a new starter joining next month, please onboard them.",
"requester": "hr@yourcompany.example",
"context": {
"person_name": "Jo Example",
"person_email": "jo@yourcompany.example",
"start_date": "2026-10-01",
"role": "Operations Analyst"
}
}{
"task": {
"id": "task_5f4c...",
"workflow_id": "employee_onboarding",
"workflow_version": "v1",
"routing_confidence": 0.95,
"routing_signals": ["new starter", "onboard"],
"status": "awaiting_approval",
"current_step": 2,
"missing_fields": []
},
"plan": [
{ "id": "raise_it_ticket", "tool": "ticketing.create_ticket", "requires_approval": false },
{ "id": "create_crm_contact", "tool": "crm.create_contact", "requires_approval": false },
{ "id": "welcome_email", "tool": "internal.notify_requester", "requires_approval": true }
],
"executions": [
{
"step_id": "raise_it_ticket",
"status": "succeeded",
"external_id": "TKT-FA22844D",
"input_payload": { "subject": "Onboarding: Jo Example", "priority": "normal" }
}
],
"pending_approval": {
"step_id": "welcome_email",
"preview": { "payload": { "to": "jo@yourcompany.example",
"subject": "Welcome — your first day" } }
},
"detail": "New starter onboarding is paused at 'welcome_email' for human approval."
}plan comes from the workflow definition, so you can see the whole sequence — including which steps will stop — before any of it runs.
| Status | Meaning |
|---|---|
completed | Every step executed |
awaiting_approval | Paused; pending_approval says on what |
needs_triage | Nothing routed, or required data was missing |
failed | A step failed; failure_reason names which and why |
cancelled | An approver rejected it |
running | In progress |
needs_triage is a 201, not an error
A request that matches no workflow, or that is missing data a workflow requires, comes back needs_triage with the reason stated and nothing executed. The request was valid and was processed to a conclusion — forcing it into the nearest process would be worse than not routing it. Route these to a person.
Workflows you control
$name binds from the task’s context. A bare $name keeps the value’s type; inside a sentence it is interpolated as text.
id: expense_approval
version: v1
name: Expense approval
intent_hints: ["expense", "reimburse", "out of pocket"]
required_fields:
- name: amount # no amount, no workflow run
steps:
- id: open_case
tool: ticketing.create_ticket
arguments:
subject: "Expense claim: $amount"
requester: $requester
- id: tell_them
tool: internal.notify_requester
requires_approval: true # this one reaches a person
arguments:
to: $requester
subject: "Your expense claim"Bad configuration fails when the file loads, not half way through a live run — a workflow with no steps, a duplicate step id, or a step naming a tool that is not registered is refused outright. A workflow that routes correctly and dies on step three has already raised a ticket and emailed a customer.
What the agent cannot do
A tool that is not registered does not exist
Naming one returns 404. There is no free-form execution surface, and no way to reach a capability the deployment was not configured with.
A registered tool outside the workflow's list is still refused
The allowlist is derived from the workflow's own steps. Being able to send email does not mean the refunds workflow may send email.
Every argument is checked before the tool runs
Against the tool's declared schema, not suggested to the model. A field the tool never declared is a 422, not something quietly dropped.
A status means a tool said so
The final state is assembled from what the connectors returned. Nothing else can mark a step successful, and the summary counts steps that executed rather than steps that were planned.
Retrying is the normal path
A step that already reached your systems is never run twice — its recorded result is reused. A network timeout does not produce a second ticket.
Retry after any outage
POST /v1/tasks/{id}/resume is safe to call repeatedly. A step that already reached an external system is not run again — its recorded result is reused — and resume will not step past an approval nobody granted.
Agent 05 · CRM Intelligence
Every answer names the records it came from.
And a write cannot be made without first being shown.
- POST
/v1/crm/askNatural-language question, answered from records - GET
/v1/crm/accounts/{id}Account, contacts and recent activity - GET
/v1/crm/rolesThe field-access and action model - GET
/v1/crm/actionsProposed and executed writes - POST
/v1/crm/actions/previewPreview a write; no side effect - POST
/v1/crm/actions/executeExecute an approved write - POST
/v1/webhooks/crmCRM change event, keeps the cache current (signed)
Asking a question
user_identity and role are how the agent respects your CRM’s permissions — they drive both which records are retrieved and which fields are readable.
{
"question": "Tell me about Northwind Supplies Ltd",
"user_identity": "rep@yourcompany.example",
"role": "sales_rep"
}{
"answered": true,
"answer": "Northwind Supplies Ltd is active, renewing 2026-11-30.",
"record_refs": ["ACC-1001"],
"records": [
{
"external_id": "ACC-1001",
"kind": "account",
"fields": { "name": "Northwind Supplies Ltd", "status": "active",
"annual_value": 48000.0, "renewal_date": "2026-11-30" },
"hidden_fields": []
}
],
"intent": "account_summary",
"structured_query": {
"intent": "account_summary",
"account_ref": "Northwind Supplies Ltd",
"filters": { "owner": "rep@yourcompany.example" },
"limit": 10
},
"confidence": 0.85,
"follow_ups": ["Northwind Supplies Ltd renews on 2026-11-30."]
}record_refs are your CRM’s own ids. Show them — they are what makes the answer checkable, and an answer that cites nothing is refused rather than shown. Note the owner filter in structured_query: it was added by the role, not by the question, and no phrasing removes it.
Roles you map onto your own
| Role | Sees | May do | Own records only |
|---|---|---|---|
admin | Everything in the tenant | Every registered action | No |
sales_manager | Every account | Tasks, contacts, opportunities | No |
sales_rep | Accounts they own | Tasks and contacts | Yes |
support | All accounts, no commercial figures | Nothing — read only | No |
read_only | Names and status | Nothing — read only | No |
Permissions narrow the query as it is built, so a record the user may not see is never loaded — it cannot surface through a count, a summary, or an error that names it. hidden_fields names what a role could not read, because silently trimming a response makes an incomplete answer look like a complete one.
A permissive role never widens a narrow key
The role governs what the person may do; the API key scope governs what the integration may do. A write needs both. An unrecognised role resolves to the narrowest one, so a typo cannot widen access.
When it refuses
| Code | Meaning | What to do |
|---|---|---|
NO_RECORDS | Nothing visible to this user matched | Check the account name, or the asker's permissions |
UNSUPPORTED_QUESTION | Not one of the intents the agent handles | Working as intended — guessing would return the wrong records |
MODEL_DECLINED | The records do not contain what was asked | The data is not in your CRM |
LOW_CONFIDENCE | Below your configured floor | Expected; tune the floor if frequent |
UNGROUNDED_ANSWER | It cited no record, so nothing supports it | Report it — should not happen |
INVALID_RECORD_REFERENCE | It cited a record that was never retrieved | Report it — a rising rate is a model problem worth seeing |
EMPTY_ANSWER | It claimed to answer and returned nothing | Report it |
Preview, then execute
A preview writes nothing and returns the token execute requires. A role that may not perform the action is refused here, so no token is ever issued for something that could not run.
{
"action_request_id": "acrq_...",
"description": "Create a task 'Chase the renewal pack'.",
"payload": { "subject": "Chase the renewal pack",
"owner": "rep@yourcompany.example" },
"will_create": true,
"is_reversible": true,
"warnings": [],
"approval_token": "apt_...",
"expires_at": "2026-09-08T09:12:00Z",
"detail": "Nothing was written. Approve with this token to execute."
}{
"action_request_id": "acrq_...",
"approval_token": "apt_...",
"approved_by": "manager@yourcompany.example"
}The token is what makes skipping the preview impossible
It expires in 30 minutes and is bound to a fingerprint of the payload, so editing the payload after approval is refused rather than sent. The external_id you get back is read from your CRM’s response, not assumed from a 200, and re-executing returns the original result rather than writing again.
Webhooks
Events flow both ways, signed the same way.
HMAC-SHA256 over the timestamp and the raw body, with a 300-second replay window.
{
"schema_version": "1.0",
"event_type": "qualification.completed",
"tenant_id": "your_tenant_id",
"correlation_id": "corr_9f2c1a0b4e7d",
"occurred_at": "2026-09-04T10:15:02.441Z",
"data": { }
}import hashlib, hmac, time
TOLERANCE_SECONDS = 300
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
timestamp, provided = parts.get("t"), parts.get("v1")
if not timestamp or not provided:
return False
# Reject old signatures so a captured request cannot be replayed.
if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
# Constant-time compare - a plain == leaks the digest through timing.
return hmac.compare_digest(expected, provided)Verify against raw bytes
Parsing the JSON and re-serializing it changes the digest, and every signature will fail. A 401 on a request you believe is signed correctly is almost always clock drift or a re-serialized body.
Events we send you
| event_type | Fired when |
|---|---|
qualification.completed | Agent 01 finishes a lead |
support.answer_completed | Agent 02 finishes a message |
document.processed | Agent 03 finishes a document |
task.status_changed | Agent 04 task changes state |
crm.action_completed | Agent 05 executes a CRM write |
More events arrive as agents ship. Ignore event types you do not recognise rather than erroring — that is what lets us add them without breaking you.
Delivery semantics
- Persisted before sent — a failed send is a retry, not a lost event.
- Retried five times with exponential backoff, then marked failed and held for requeue. Never discarded.
- At-least-once — make your handler idempotent, keying on identifiers inside data rather than arrival order.
- Respond fast — return 2xx once durably accepted and do the work asynchronously.
Events you send us
| Path | Accepts | Agent |
|---|---|---|
POST /v1/webhooks/crm | contact.*, opportunity.*, account.* | 01 and 05 |
POST /v1/webhooks/tickets | ticket.updated, ticket.solved, ticket.closed | 02 |
POST /v1/webhooks/email | An inbound support email | 02 |
POST /v1/webhooks/document | object.created — a document that landed in storage | 03 |
POST /v1/webhooks/events | Any event that should start a workflow | 04 |
One event, more than one agent
A deleted contact closes the matching lead and invalidates the cached account, so /v1/webhooks/crm offers the event to both and reports what each did. An event no agent in your deployment consumes comes back handled: false with a 200, not an error. A CRM that emits more than we read is normal, and a 4xx would make it retry forever.
Your systems
Agents never import a vendor SDK.
They ask for a kind of system — “the CRM” — and get whatever is bound to it. That is why connecting Salesforce instead of the mock changes no agent logic.
class Connector(ABC):
name: str
kind: ConnectorKind # crm | ticketing | email | calendar
# storage | rest | project_management
def authenticate(self) -> bool: ...
def health_check(self) -> dict: ...
def read(self, ctx, *, resource, params=None) -> list[dict]: ...
def preview_action(self, ctx, *, action, payload) -> ActionPreview: ...
def execute_action(self, ctx, *, action, payload,
idempotency_key=None) -> ActionResult: ...
def handle_webhook(self, ctx, *, event_type, payload) -> dict: ...The two methods that matter
preview_action
Describes the write without performing it, so a human, a policy gate or a demo can see exactly what would happen. It must have no side effects. Use its warnings generously — an unexpected duplicate, a field that will be overwritten, a quota nearly exhausted.
execute_action
Performs the write. Honouring the idempotency key is not optional: the platform retries, and a connector that ignores the key will create duplicate records in your system.
What a connector must do
- Return the external ID — it is how a CrewTAC record reconciles with yours, and it appears in the audit trail.
- Persist the idempotency mapping durably, in your database rather than process memory, or a restart between retries defeats it.
- Report failure honestly. A connector that swallows an error is worse than one that fails loudly.
- Scope every query by tenant. It is the only thing standing between two customers’ data.
- health_check must never raise — it feeds the readiness probe.
Your connector lives in your repository, not ours
Your integration logic — endpoint shapes, field mappings, business quirks, credential handling — is yours. It stays in a repository you control, deploys alongside the platform, and registers at startup. Nothing proprietary to your organisation enters the CrewTAC codebase, and nothing in the CrewTAC codebase depends on your systems existing.
Testing your connector
| Test | Why |
|---|---|
| read returns only the calling tenant’s records | Highest-severity failure mode |
| preview_action performs no write | Its entire purpose |
| execute_action returns an external ID | Reconciliation depends on it |
| Same idempotency key twice creates one record | The retry path — the most common connector bug |
| A downstream 500 surfaces as failure, not success | Silent failure corrupts the audit trail |
If you would rather not write code
Generic REST connector
Configure endpoint URLs, auth and field mappings for a system with a conventional API. No code.
Webhooks only
Skip connectors entirely. We publish results; your integration layer does the writing. You keep full control of every write.
We build it
Connector development is in scope for a paid integration engagement, delivered into your private repository.
The second option is often the right first step: value flows in days, and the connector decision waits until you know which writes you actually want automated.
Security and data
Written for the questions a security review asks.
| Control | How it works |
|---|---|
| API keys | Stored as SHA-256 hashes; plaintext shown once at creation and never recoverable |
| Scopes | Every endpoint declares what it needs; a missing scope returns 403 naming it |
| Tenant isolation | Every table carries tenant_id; every read filters on it inside the service layer, not only at the router |
| Webhook signatures | HMAC-SHA256, constant-time compared, with a 300-second replay window |
| Key revocation | Effective on the next request, with no restart |
Requesting another tenant’s record ID returns 404, not 403 — we do not confirm that an ID exists in a tenant you cannot see.
Never stored
- Rendered prompts — only the prompt version is persisted.
- Secrets in audit metadata — keys named password, secret, token, api_key, authorization, prompt or ssn are redacted on write, recursively.
- Plaintext API keys.
Model providers and your data
- No model is fine-tuned on your data. Nothing you send trains anything.
- Answer-quality feedback feeds an evaluation harness only.
- Local and evaluation environments run deterministic offline providers that make no network call at all.
- Data residency and provider choice are deployment decisions — state a residency requirement during onboarding.
Guardrails you can rely on
Human-in-the-loop
Anything reaching a person outside your organisation waits for approval by default. Low model confidence escalates an external write regardless of your settings.
Grounded answers
Several checks never consult the model’s own confidence: was anything retrieved, did it clear the relevance floor, does every citation point at a passage actually supplied.
Regulated advice is refused
Medical, legal, financial and tax questions escalate to a person even when the knowledge base appears to cover them.
Prompt injection
Retrieved documents are fenced and marked as data, never instructions. Attempts to redirect the agent are flagged and routed to a human.
Deterministic rules
Consent checks, blocklists, scoring and routing bands are code, not prompts. The same input always produces the same score.
Going live
Work through this before production traffic.
Amber markers are the items that take real time — and the ones worth the time.
Your client code
- Idempotency-Key sent on every write
- Retry with backoff on retryable errors, reusing the same key
- Branching on error.code, never on error.message
- 429 handling implemented, even though limits are not enforced yet
- Unknown response fields ignored rather than rejected
- X-Correlation-ID sent and logged with your own request ID
Webhooks
- Signature verification unit-tested, including a tampered-body case that must fail
- Verifying against raw bytes, not re-serialized JSON
- Handler is idempotent and returns 2xx quickly
- Retry behaviour tested by deliberately returning 500 (allow extra time)
Connectors
- The connector inventory endpoint shows your real connectors, not mocks
- Credentials least-privilege, from a secret manager
- Idempotency mapping persisted durably, not in process memory
- preview_action output diffed against expected writes (allow extra time)
Agent 01 — Sales Qualification
- consent_status sends the real value, not a hard-coded “granted”
- Approval workflow built — someone sees pending actions and acts
- Reason codes surfaced to reps; the score alone is not persuasive
- Scoring reviewed against ~50 leads your team has already judged (allow extra time)
Agent 02 — Customer Support
- Knowledge base ingested, approved, and structured with headings
- Re-ingestion job scheduled to keep knowledge current
- Escalation path tested end to end into your real help desk
- Feedback capture wired into your agent console
- Baseline measured: 50 real historical questions checked before any customer sees an answer (allow extra time)
Monitoring and rollout
- Alerts on connector failures, escalation share and webhook failures
- Readiness probe wired to your monitoring
- Logs indexed on correlation_id
- Start with a subset — one lead source, one support category, or a traffic percentage
- A kill switch you control, and a rollback plan that does not depend on us
The first two weeks
Expect to tune. Nothing below indicates a broken system — and the override and escalation audit trails are your tuning data. Read them weekly for the first month.
| What you will see | What it means | What to do |
|---|---|---|
| Agent 02 escalating more than expected | Documentation gaps, usually | Publish the missing answers — not a lower threshold |
| Agent 01 scores clustered in one band | Weights not tuned to your market | Adjust the scoring table and bump the rules version |
| Reps overriding one specific recommendation | Routing bands disagree with your process | Review the override audit events; adjust the bands |
| Occasional fallback classifications | Model provider blipped | Nothing — leads were still scored and flagged |
Endpoint-level reference and the machine-readable OpenAPI specification are issued with API access. Back to documentation.
Next step
Ready to connect a system?
Tell us which platform you want the agent to work in — CRM, help desk, or something bespoke — and we will scope the integration against your API.
Or email sales@crewtac.com