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. 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. 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. 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 website1
Push qualified leads into your warehouse1 + 2
Have the agent create the record in your CRM1 + 3
Answer support questions from your documentation1
Open tickets in your existing help desk1 + 3
Keep your help desk and the agent in sync both ways1 + 2 + 3

Onboarding path

Six steps, each independently useful.

You can stop at any point and still have something working.

  1. Connect and call once

    Get a key, submit one record, read the response. Everything runs against mock connectors, so nothing touches your production systems.

  2. 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.

  3. Connect one real system

    Usually the CRM or help desk. Use preview_action to see exactly what would be written before enabling writes.

  4. Turn on outbound webhooks

    Your systems now receive results as they happen.

  5. 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.

  6. 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.

bashevery request
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.

ScopeGrants
leads:readRead leads, qualifications, actions and their audit trail
leads:writeSubmit leads and run the qualification workflow
leads:approveApprove, reject or override a recommended action
support:readRead conversations, messages and citations
support:writeSubmit questions and record feedback
support:escalateOpen a ticket explicitly
knowledge:readList knowledge documents
knowledge:writeIngest and version knowledge documents
documents:readRead documents, extractions and the review queue
documents:writeUpload documents for processing
documents:reviewApprove, correct or reject an extraction
documents:exportSend an approved result to a destination system
tasks:readRead tasks, workflows, tools and the approval queue
tasks:writeStart and resume workflows
tasks:approveApprove or reject a paused step
tools:executeExecute a single tool for real, rather than previewing
crm:readAsk CRM questions and read accounts
crm:writePreview and execute CRM changes
comms:sendTools that email or invite someone outside your organisation
tickets:writeTools that create or update tickets
webhooks:receivePost inbound events to CrewTAC
webhooks:publishRead and dispatch queued outbound events (POST /v1/webhooks/dispatch)
audit:readRead 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.

bashPOST /v1/leads
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" }'
SituationResponse
First call201 with the result
Retry — same key and same body200, the original response, header Idempotent-Replay: true. Nothing re-executes.
Same key, different body409 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.

jsonevery failure
{
  "error": {
    "code": "validation_error",
    "message": "Human-readable explanation.",
    "retryable": false,
    "details": {},
    "correlation_id": "corr_9f2c1a0b4e7d"
  }
}
CodeHTTPRetryUsual cause
validation_error422NoMalformed or missing field
authentication_error401NoMissing, invalid or revoked key
permission_denied403NoKey lacks the required scope
not_found404NoUnknown ID, or an ID from another tenant
idempotency_conflict409NoKey reused with a different body
policy_violation422NoAction blocked by a guardrail
not_implemented501NoEndpoint belongs to an agent not yet built
rate_limited429YesThrottled
connector_error502YesA downstream system failed
llm_provider_error502YesModel provider unavailable
internal_error500YesUnexpected 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)
requestPOST /v1/leads
{
  "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" }
}
response201 Created
{
  "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_typeReaches the prospectDefault
book_discovery_callYesWaits for approval
send_nurture_sequenceYesWaits for approval
request_more_informationYesWaits for approval
assign_to_sales_repNoRuns; escalates if confidence is low
hold_no_consentNoRuns
disqualifyNoRuns

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.

requestadmin only
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

responsePOST /v1/support/messages
{
  "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

CodeMeaningWhat to do
NO_KNOWLEDGENothing retrievedContent gap — publish the answer
WEAK_RETRIEVALBest passage below the relevance floorContent gap, or thresholds need calibrating
NOT_ANSWERABLEPassages found but they do not cover itContent gap
INVALID_CITATIONCited a passage never suppliedReport it — should not happen
UNGROUNDED_ANSWERClaimed support but cited nothingReport it
LOW_CONFIDENCEBelow your confidence floorExpected; tune the floor if frequent
RESTRICTED_TOPICMedical, legal, financial or tax adviceWorking as intended
SENSITIVE_CATEGORYData privacy or cancellationWorking as intended
HUMAN_REQUIREDComplaint, security, legal threat or distressWorking as intended
INJECTION_ATTEMPTInstructions aimed at the AIReview the conversation
NEEDS_CLARIFICATIONToo ambiguous — the agent asked backNot 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.

bashPOST /v1/documents
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.

json201 Created
{
  "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.

StatusMeaning
processedValidated, nothing high risk, ready to export
needs_reviewA person must look at it; the review task says why
approvedA reviewer approved it; it can be exported
rejectedRefused at the safety gate, or rejected by a reviewer
failedText 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.

yamlinvoice_v1.yaml
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

CodeMeaningWhat to do
HIGH_RISK_FIELDA field your schema marks as always-reviewWorking as intended — a confident wrong total is still a wrong payment
VALIDATION_FAILEDA value broke a format, range or list constraintCheck the value against the page
CROSS_FIELD_RULE_FAILEDThe totals or the dates do not reconcileThe document is likely wrong, not the reading
REQUIRED_FIELD_MISSINGA required field was not on the documentCheck whether it is genuinely absent
UNKNOWN_DOCUMENT_TYPENo registered type matchedAdd a schema, or route it elsewhere
LOW_CLASSIFICATION_CONFIDENCEIt may have been read against the wrong schemaConfirm the type before trusting the fields
LOW_FIELD_CONFIDENCEA specific value was hard to readExpected on poor scans; tune the floor if frequent
LOW_DOCUMENT_CONFIDENCEThe document as a whole was hard to readUsually a scan-quality problem at source
OCR_SOURCEThe text is a transcription, not the document's ownWorking as intended
EXTRACTION_UNAVAILABLEThe model could not be reachedReport 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.

jsonPOST /v1/documents/{id}/export
{
  "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.

requestPOST /v1/tasks
{
  "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"
  }
}
response201 Created
{
  "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.

StatusMeaning
completedEvery step executed
awaiting_approvalPaused; pending_approval says on what
needs_triageNothing routed, or required data was missing
failedA step failed; failure_reason names which and why
cancelledAn approver rejected it
runningIn 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.

yamlexpense_approval_v1.yaml
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.

requestPOST /v1/crm/ask
{
  "question": "Tell me about Northwind Supplies Ltd",
  "user_identity": "rep@yourcompany.example",
  "role": "sales_rep"
}
response200 OK
{
  "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

RoleSeesMay doOwn records only
adminEverything in the tenantEvery registered actionNo
sales_managerEvery accountTasks, contacts, opportunitiesNo
sales_repAccounts they ownTasks and contactsYes
supportAll accounts, no commercial figuresNothing — read onlyNo
read_onlyNames and statusNothing — read onlyNo

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

CodeMeaningWhat to do
NO_RECORDSNothing visible to this user matchedCheck the account name, or the asker's permissions
UNSUPPORTED_QUESTIONNot one of the intents the agent handlesWorking as intended — guessing would return the wrong records
MODEL_DECLINEDThe records do not contain what was askedThe data is not in your CRM
LOW_CONFIDENCEBelow your configured floorExpected; tune the floor if frequent
UNGROUNDED_ANSWERIt cited no record, so nothing supports itReport it — should not happen
INVALID_RECORD_REFERENCEIt cited a record that was never retrievedReport it — a rising rate is a model problem worth seeing
EMPTY_ANSWERIt claimed to answer and returned nothingReport 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.

responsePOST /v1/crm/actions/preview
{
  "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."
}
requestPOST /v1/crm/actions/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.

envelopeoutbound
{
  "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": { }
}
pythonverification
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_typeFired when
qualification.completedAgent 01 finishes a lead
support.answer_completedAgent 02 finishes a message
document.processedAgent 03 finishes a document
task.status_changedAgent 04 task changes state
crm.action_completedAgent 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

PathAcceptsAgent
POST /v1/webhooks/crmcontact.*, opportunity.*, account.*01 and 05
POST /v1/webhooks/ticketsticket.updated, ticket.solved, ticket.closed02
POST /v1/webhooks/emailAn inbound support email02
POST /v1/webhooks/documentobject.created — a document that landed in storage03
POST /v1/webhooks/eventsAny event that should start a workflow04

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.

pythonthe connector interface
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

TestWhy
read returns only the calling tenant’s recordsHighest-severity failure mode
preview_action performs no writeIts entire purpose
execute_action returns an external IDReconciliation depends on it
Same idempotency key twice creates one recordThe retry path — the most common connector bug
A downstream 500 surfaces as failure, not successSilent 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.

ControlHow it works
API keysStored as SHA-256 hashes; plaintext shown once at creation and never recoverable
ScopesEvery endpoint declares what it needs; a missing scope returns 403 naming it
Tenant isolationEvery table carries tenant_id; every read filters on it inside the service layer, not only at the router
Webhook signaturesHMAC-SHA256, constant-time compared, with a 300-second replay window
Key revocationEffective 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 seeWhat it meansWhat to do
Agent 02 escalating more than expectedDocumentation gaps, usuallyPublish the missing answers — not a lower threshold
Agent 01 scores clustered in one bandWeights not tuned to your marketAdjust the scoring table and bump the rules version
Reps overriding one specific recommendationRouting bands disagree with your processReview the override audit events; adjust the bands
Occasional fallback classificationsModel provider blippedNothing — 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