Guides

Autonomous resolution agent

AdminUpdated Sep 19, 2026

Autonomous resolution agent

An AI agent with tools — it resolves what it can, hands off what it can't, and stops when the workspace budget says stop.

The autonomous agent is the difference between "chatbot that drafts replies" and "chatbot that closes tickets." It has tools, a bounded loop, an allow-list of what it may call, and an escape valve to a human agent.

It runs in workspaces where you want end-to-end ticket deflection — order lookups, FAQ answers, ticket filing — without an agent in the loop.

Warning — Always pair it with a copilot fallback

Even the best autonomous setups hand off a large share of inbound to humans. You don't want those humans starting from scratch — keep the copilot on for the rest of the team so the hand-off feels seamless.

When to enable it

Good fit:

  • Comprehensive KB covering the top FAQ topics.

  • Stable, well-documented internal APIs you can call as custom HTTP tools (orders, billing, account state).

  • Volume that justifies it.

Bad fit:

  • Brand-new workspace with a thin KB.

  • High-stakes flows (medical, legal, financial advice) — let it draft, don't let it send.

  • Every ticket is bespoke (e.g. enterprise B2B sales).

Bots are configured per workspace under Bots (/bots), and routed to conversations by ai_bot_routes. A bot's status is active, paused or draft; paused and draft bots are skipped.

The loop

run queued (autonomous_runs row + BullMQ `autonomous_run` job)
   │
   ▼
claim the run, mark `running`
   │
   ▼
load transcript — last 20 chat-y messages + the kickoff message
   │
   ▼
RAG — retrieveRelevant() on the latest customer turn,
      injected into the system prompt
   │
   ▼
pick a provider: workspace OpenAI BYOK → workspace Anthropic BYOK
                 → internal AI service (single-shot, no tools)
   │
   ▼
tool loop  ── up to `maxToolCalls` iterations
   │            each iteration: thread + tool defs → LLM
   │            tool calls execute in their own short transaction
   │            hitting the cap forces a "no more tools, final reply" turn
   ▼
guardrails on the final reply
   │
   ▼
persist a bot-authored message + `completed | failed` on the run row

Each phase runs in its own short Postgres transaction, and the LLM loop holds none — LLM calls take seconds, and holding a connection across them exhausts the pool.

The limits that actually exist

Parameters

Name

Type

Description

maxToolCalls

integer

Maximum tool-loop iterations before the agent is forced to produce a final reply. Editable in the bot editor, clamped to 1–32. Default: 8.

enabledTools

string[]

The allow-list. selectTools() filters the catalog by exact key match — a bot with an empty list gets no tools at all.

kbScope

object

{ collectionIds } narrows RAG retrieval to a subset of the KB. Empty means the whole workspace KB.

guardrails

object

maxOutputChars (64–20,000), bannedKeywords (up to 50), piiPolicy (allow | redact | deny). Applied to the final reply. Settable over the API and carried by persona templates — the bot editor has no field for them.

escalationRules

rule[]

Structured rules. Trigger: sentiment_negative, keyword, tool_failure, after_turns. Action: human.handoff or apply_tag.

Warning — There is no per-turn wallclock or per-conversation dollar cap

The bounds on a run are the tool-loop iteration cap, the escalation rules, the guardrails, and the workspace AI spend ceiling. There is no wallclock timeout on a turn and no per-conversation USD cap. If you need a hard money bound, set it at the provider — your OpenAI or Anthropic account is where the tokens are actually billed. See cost metering for what the plan ceiling does and does not bound.

Tools

Built-in catalog

These are the keys a bot's enabledTools list can hold. One list, shared by the dashboard picker and the worker's executor, so what you tick is what runs.

Built-in tools

Name

Type

Description

search_kb

built-in

Search the workspace knowledge base. Scoped by the bot's kbScope.

get_contact_attribute

built-in

Read one attribute off the contact.

set_contact_attribute

built-in

Write one attribute onto the contact.

tag_conversation

built-in

Apply a tag. Capped at 64 characters.

change_status

built-in

Move the conversation between statuses.

assign_to_team

built-in

Assign the conversation to a team.

escalate_to_human

built-in

Hand off. Routes through the API's escalate endpoint so the conversation_escalations row and the conversation.escalated envelope match the visitor and agent paths.

create_ticket

built-in

Open a ticket linked to this conversation. subject required; priority is low | normal | high | urgent.

create_linear_issue

built-in

Uses the workspace's existing Linear integration install.

notify_slack

built-in

Uses the workspace's existing Slack integration install.

get_last_messages

built-in

Re-read recent turns in the thread.

lookup_shopify_order

built-in

Offered to every workspace; answers not_configured when Shopify is not installed rather than pretending.

Info — The allow-list is the gate, not a permission field

Tools do not carry a permission string. An autonomous bot has no user actor to check a permission against, so a permission-shaped field would gate nothing — and a control that looks like a control but isn't is worse than none. What a bot may call is the workspace admin's enabledTools list, and nothing else.

Custom HTTP tools

You wire your own systems as HTTP tools under Bots → AI tools (/ai-tools), or over the API:

GET /v1/ai/tools — Bearer token

POST /v1/ai/tools — Bearer token

PATCH /v1/ai/tools/{id} — Bearer token

DELETE /v1/ai/tools/{id} — Bearer token

Reads need ai:read; writes need ai:write — teaching the agent to POST somewhere with a stored credential is materially more powerful than editing a canned reply.

{
  "name": "orders_find",
  "description": "Look up an order by ID. Use when the customer asks about a specific order.",
  "method": "GET",
  "urlTemplate": "https://api.acme.com/orders/{{orderId}}",
  "headers": { "Accept": "application/json" },
  "secretHeaders": { "Authorization": "Bearer sk_live_..." },
  "bodyTemplate": null,
  "parameters": [
    {
      "name": "orderId",
      "type": "string",
      "required": true,
      "description": "The order number the customer gave you"
    }
  ],
  "timeoutSeconds": 20,
  "enabled": true,
  "requiresApproval": false,
  "approvalThresholdParam": null,
  "approvalThresholdValue": null
}

Notable fields

Name

Type

Description

secretHeaders

object

AEAD-encrypted at rest and never returned by a read — reads project the key names only. Omit the key on a PATCH to keep the stored value; send null to clear it.

urlTemplate

string

The host must be static. A template whose host contains a placeholder is rejected at write time, so the model can never choose where a credentialled request goes. The URL is checked against the outbound guard both when saved and when called.

parameters

array

At most 16. Server-supplied parameters are omitted from the schema the model sees.

timeoutSeconds

integer

Per-call timeout. Default: 20.

requiresApproval

bool

Hold every call for a human decision before it runs. Default: false.

approvalThresholdParam / Value

string / number

Conditional approval — hold only when this numeric argument exceeds the value. Small refunds pass, large ones wait for a person.

Pending approvals are listed at GET /v1/ai/tool-calls and decided with POST /v1/ai/tool-calls/{id}/decide.

Info — Custom tools are resolved per workspace, per call

They are never registered into a shared process-wide registry. A tool carrying one workspace's credentials must not be visible to another workspace's agent in the same process, and a shared map is exactly how that happens.

Order lookup preset

Writing that JSON by hand is the reason most stores never do it, so the AI tools screen ships an order lookup preset. One field — the URL of your own endpoint — and one click creates an ordinary custom tool. There is no separate "preset" object: the row it writes is indistinguishable from a hand-written one, so everything on this page applies to it unchanged.

On Shopify, connect the Shopify integration and tick lookup_shopify_order instead. The preset exists for WooCommerce, BigCommerce, Squarespace and bespoke backends, which have no integration to connect.

Chatly calls your endpoint like this:

GET https://api.yourstore.com/chatly/order-lookup?order_number=1042&email=ada%40example.com
Accept: application/json
Authorization: <your shared secret, if you set one>

Both values are percent-encoded, and the two parameters are appended with & if your URL already carries a query string. Answer HTTP 200 with a small JSON object:

{
  "found": true,
  "order_number": "1042",
  "status": "shipped",
  "placed_at": "2026-08-20T09:14:00Z",
  "items": [{ "name": "Blue mug", "quantity": 2 }],
  "total": "34.00",
  "currency": "USD",
  "tracking_url": "https://track.example.com/XYZ",
  "estimated_delivery": "2026-08-30"
}

Anything past 8,000 characters is truncated before the model sees it, so answer the question rather than returning the order record.

Warning — Match on the email, not only the order number

Order numbers are sequential and guessable. An endpoint that answers on the number alone will read somebody else's order out loud to whoever tries #1041. Answer with found: false when the number and the email disagree.

The email argument is filled in by Chatly from

contacts.email and is omitted from the schema the model sees, so a visitor cannot talk the agent into sending a different one. Its assurance is the channel's — a pre-chat form, an identify() call, or the address an email conversation arrived from. Treat it as an ordinary support-agent level of confidence, not proof of purchase.

Warning — Return a non-2xx when your order system is down

Never answer 200 with found:false for an outage. The model cannot tell an outage from an empty result, and it will tell the customer their order does not exist while sounding completely certain. A non-2xx flips toolFailed, so a tool_failure escalation rule can fetch a human instead.

A conversation with an anonymous visitor has no contacts.email, and the call is refused before it dials with missing required argument(s): email handed back to the model, which then asks the customer to identify themselves. Capture an email in the pre-chat form if you want the agent to answer on the first turn.

Handoff

The agent hands off by calling escalate_to_human(reason). That routes through the API's escalate endpoint, so the escalation row, the idempotency and the realtime envelope are identical to a visitor- or agent-initiated handoff. When the AI service cannot reach the API, the run still flags the handoff — the visitor-facing reply switches to a transfer message either way — but records handoffSubmitted: false so a degraded handoff is distinguishable from a completed one.

Escalation rules fire the handoff without waiting for the model to choose it. Configure them per bot; the trigger types are sentiment_negative, keyword, tool_failure and after_turns.

Running out of AI budget also forces a handoff: the run escalates, the visitor gets a reply and a person, and the workspace gets a notice. The escalation sets the conversation to pending, which is the state bot-routing stands down on — so the next inbound message does not start another run.

What customers see

  • Bot replies are authored as bot messages and rendered with their own bubble style in the widget.

  • Below the last bot message that ends an AI exchange, the widget shows two deflection chips: "This answered my question" and "Not really — talk to a human." The second one escalates.

  • Bot replies do not carry inline KB citations today. The retrieved chunks ground the answer; they are not rendered as clickable footnotes to the customer.

Observability

Autonomous runs are a durable ledger, not just logs. autonomous_runs records every invocation, and the dashboard reads it at Bots → (a bot) → Logs:

GET /v1/autonomous-agents/runs — Bearer token

GET /v1/autonomous-agents/runs/{id} — Bearer token

All four run endpoints require a signed-in session carrying triggers:read. The run list filters by status, date range and conversation id. The detail drawer shows duration, input and output tokens, cost, the final reply or failure, and every tool call with its raw arguments and result. GET /v1/autonomous-agents/runs/_tools and .../_sparklines back the 7-day tool-usage and run-volume panels.

This is the panel you use to debug "why did the bot say X?"

Cost

Autonomous runs are priced per model against the rate table and recorded on the workspace's BYOK spend meter, and the per-run cost is shown in the run detail. See cost metering.

With no model pinned, the worker's autonomous loop defaults to claude-sonnet-4-5 on an Anthropic key and gpt-4o-mini on an OpenAI key — the loop is customer-facing, so the Anthropic default is the mid-tier model rather than the cheap one.

Troubleshooting

Warning — The bot answers with text and never uses a tool

Check the bot's enabled tools list. An empty list is indistinguishable from a bot deliberately configured without tools, and the model simply has nothing to call. Confirm the keys match the catalog above.

Warning — The bot escalates everything to humans

Look at the escalation rules first — an after_turns or keyword rule that matches too broadly will fire before the model gets a chance. Then check Bots → Logs for runs that failed, and for the AI-budget escalation reason.

Info — Loops on the same tool call

The bot is chasing a changing result. Check the tool's idempotency — it should return the same answer for the same args within a turn. Failing that, lower maxToolCalls; hitting the cap forces a final reply rather than another hop.

Info — Tools are ticked but the bot has no memory of the KB

RAG only injects context when the workspace has an embedding provider. Without one, retrieval degrades to keyword search — see RAG + Knowledge Base.

Was this page helpful?