docs
/
Courses — AppEngine

AppEngine: Real-time Chat and AI Support Integration

Watch on YouTube ↗

Video chapters
  1. AppEngine real-time chat and AI support integration
  2. One conversation, different participants
  3. Prepare identities and reuse the official client
  4. Configure the assistant for support
  5. Connect the assistant and controlled support staff
  6. Choose the customer or website guest route
  7. Connect and wait for AppEngine authentication
  8. Open the real Flutter support screen
  9. Send a question and assemble its answer
  10. Let a person continue the conversation
  11. Reconnect without losing the human handoff
  12. Typing, read status and attachments
  13. Handle waiting customers and staff availability
  14. Return to AI and close deliberately
  15. Reuse the contracts from browser, Node and Python
  16. Start and retain a website guest session
  17. Let the assistant hand over to a person
  18. Configure a leave-message route while away
  19. Submit an offline request and open the saved ticket
  20. Handle errors without inventing success
  21. A working support integration and its boundaries

Written guide

Build a support experience that connects your customer application to an AppEngine assistant and a human support team. This course uses the existing official Flutter chat package, the actual AppEngine Socket.IO gateway, and an isolated Appmint Studio configuration. The main recorded flow uses a signed-in training customer. Website guest sessions are a separate integration branch with their own app-identity prerequisite; they are not interchangeable with the Flutter controller's signed-in conversation model.

Video production is in progress. Actual AI answers, staff takeover, reconnect routing and stored history have been verified. An illustration explains relationships; an actual UI capture demonstrates the application. Additional capture and advanced-branch evidence are identified explicitly below.

1. What you will build

The customer opens support, asks which Flutter package to use, receives a real assistant answer, and continues with a staff member. Reloading the app should restore the same conversation and human assignment. An attachment should be part of a saved chat message, not merely a file uploaded somewhere.

The support conversation connects a customer, AI assistant and staff member

Your app owns its screens and sign-in lifecycle. AppEngine provides the chat gateway, message history, assistant execution and staff routing. The official appmint_flutter_chat package supplies a Flutter controller, Socket.IO service and rendered chat screen. This course's host adapter supplies identity, configuration, session persistence and a file-picker callback. It does not replace that package or present a mockup as a functioning client.

2. Prepare the exact prerequisites

Exact integration context and current API contract

Use your organization's actual AppEngine API address and organization ID. Prepare one authorized staff account and a customer account you control. Keep a separate browser or process for each role.

  • Database and Repository APIs, chapter 2: staff identity and organization demonstrates staff sign-in at 1:02. Its written setup section supplies the required endpoint, orgid and token.
  • API Foundations demonstrates customer sign-in and session handling. Complete any second-factor or password-change requirement before treating its result as a usable customer session.
  • The official Appmint client repository contains appmint_flutter_chat and the HTTP clients. The example repository contains appmint_flutter_chat_demo. The Flutter chat example documentation explains the original host application.

Customer sign-in is POST /profile/customer/signin, with orgid and a JSON body containing email and password. The tested result has customer, token, refreshToken and orgId. The customer record's data supplies its email and display name. Never place a staff token into the public website or customer mobile application.

The course adapter keeps the customer token in memory. After reloading it asks the person to sign in again; Shared Preferences retains only the chat session identifier under the normalized email and organization. That is sufficient to demonstrate connection continuity without storing a password in the application bundle. A production host must implement its own appropriate session refresh and secure storage lifecycle.

3. Understand the identifiers before connecting

Customer identity and chat session have separate purposes

IdentifierPurpose
Organization IDSelects the tenant for the connection and its configuration.
configIdThe saved chat_config record that selects the assistant and notification recipients.
Assistant nameStable assistant handle used by the configuration and resume action.
chatIdIdentifies the conversation being read, replied to or handed over.
chatSessionIdReturned by socket authentication and retained across reconnects; also part of a guest's conversation address.
messageIdThe accepted customer message and the associated AI stream correlation identifier.
Stream end.skThe saved AI answer record, when an answer was persisted.

The current signed-in Flutter controller uses customer::{email}. A guest thread uses customer:{chatSessionId}:{email}. Do not derive a guest thread using the signed-in formula or substitute the configuration ID for the conversation ID.

4. Create the support assistant

Use the staff session for POST /crm/ai-assistant. The following is a complete setup payload for the limited support example. Choose a unique name in your organization and carry it into the chat configuration.

{
  "name": "integration-help",
  "title": "Integration Help",
  "description": "Help with the Appmint chat integration",
  "personality": "Clear, brief and friendly. Use at most two sentences per answer.",
  "behaviorRules": [
    "Answer from these facts: appmint_flutter_chat provides the Flutter support chat screen. The customer session token belongs in the Socket.IO handshake. Appmint Studio is the staff administration interface.",
    "If the visitor asks for a person, say a staff member can take over. Do not claim to have inspected deployment secrets."
  ],
  "tools": [],
  "status": "active"
}

Keep the returned sk and stable name. The live course used integration-help-1010. The saved record contains its configuration inside data.

tools: [] explicitly grants no action tools. In the current assistant service, omitted or null tools means all available tools; it is not equivalent to an empty array. Start with the exact permitted action set. The actual chat demonstration tests the assistant's answer; successful configuration creation alone would not prove the answer is useful.

Complete assistant setup request

5. Save and inspect the chat configuration

Use PUT /repository/create with staff authorization. This is configuration data; sending a customer message later is a separate Socket.IO operation.

{
  "datatype": "chat_config",
  "isNew": true,
  "data": {
    "name": "integration-support",
    "title": "Integration Support",
    "status": "online",
    "ai": "integration-help",
    "agents": [{ "user": "YOUR_CONTROLLED_STAFF_EMAIL" }],
    "offline": { "showForm": true },
    "missedChat": { "enabled": false }
  }
}

Save the returned sk as configId. Read it back through GET /chat/config/{configId} with the organization header. The actual response retains the record envelope, with the assistant and agent list inside data. The agents list directs alerts; it is not a proven access-control boundary on every staff queue action.

In Appmint Studio, open Chat Designer, open its configuration selector, and select the saved configuration by name. The course recording selects integration-support-1010, then shows Integration Help in the AI assistant control and one configured agent. The unsaved Untitled designer is not the saved configuration.

Actual saved Chat Designer configuration

The designer's sample conversation is an editor preview. Its canned pricing messages are not the actual customer/assistant proof. The live proof is in the Flutter screen and saved chat history below.

6. Establish the Socket.IO connection

Exact integration context and current API contract

Install all listeners before connecting. A greeting can arrive during the new-session sequence.

const socket = io(base + "/chat", {
  autoConnect: false,
  transports: ["websocket"],
  auth: {
    token: customerToken,
    orgId,
    configId,
    context: savedSessionId ? { chatSessionId: savedSessionId } : {}
  }
});
socket.on("authenticate", handleAuthentication);
socket.on("chat-stream", handleStream);
socket.on("message", handleMessage);
socket.on("agent-assigned", handleAgent);
socket.on("chat-ended", handleClose);
socket.on("connect_error", showConnectionError);
socket.connect();

This is Socket.IO, not a raw WebSocket API. The namespace is /chat; do not turn event names into invented HTTP endpoints. WebSocket transport is explicitly selected because it is the tested transport for this deployment.

Wait for the application-level authenticate result. Its relevant structure is:

{
  "success": true,
  "user": { "email": "CUSTOMER_EMAIL", "name": "CUSTOMER_NAME", "role": "customer" },
  "chatSessionId": "SERVER_RETURNED_SESSION_ID",
  "isGuest": false,
  "config": { "configId": "SAVED_CONFIG_ID", "ai": "ASSISTANT_NAME" }
}

user is nested. Preserve the returned session ID, and enable sending only after authentication succeeds. A transport-level connect event is insufficient. The actual configuration result may contain additional fields. A previously assigned customer may also receive a handoff object with the retained chat and staff email.

7. Run the official Flutter interface

The course's flutter/lib/main.dart imports package:appmint_flutter_chat/appmint_flutter_chat.dart. It passes AppmintChatConfig to the unchanged AppmintSupportChatScreen.

The required host inputs are endpoint, orgId, AppmintChatUser and the token callback. This course additionally supplies configId, support name, loadSessionId and saveSessionId. The token callback returns the current customer token each time a connection is needed. The official example demonstrates a richer host authentication client and refresh wiring; the compact course host sends challenge cases back to the sign-in prerequisite rather than pretending to implement full authentication.

Build the course adapter with Flutter, then start its included serve.mjs. That server serves the compiled web app and /course-settings, containing only the non-secret endpoint, organization and configuration ID. A generic static file server will not provide that settings route. The downloadable README gives the exact commands and expected sibling repository layout.

Sign in and open support. The real recorded customer, Nina, receives a greeting from the configured assistant. The captured screen is the web build of the actual Flutter package; it is not a native Android/iOS installation claim.

Actual official Flutter screen after sign-in

8. Send a message and verify the answer

Emit chat-message with message and the current chatSessionId. The acknowledgement includes success, messageId, chatId and the selected assistantId when routed to AI. The acknowledgement confirms message acceptance, not answer completion.

The recorded customer asks Which package adds support chat to a Flutter app? The actual assistant answer names appmint_flutter_chat. Readback in 25-captured-customer-history.json contains the greeting, that question and its saved answer for this recorded customer. Earlier API correlation tests used a separate controlled customer; their identifiers are not substituted into Nina's UI footage.

Actual completed assistant reply in Flutter

Accumulate nonempty chat-stream chunks by messageId. On its corresponding end, reconcile the saved answer ID. A greeting has a separate messageId and cannot complete an unrelated question. Inspect the actual answer text: an AI failure can produce fallback text followed by an end event and human handoff, rather than a distinct stream-error event.

To verify persistence, call chat-history for the intended chat. The current history result includes IDs, content, sender information, files and metadata where present. Match the question, saved AI answer and text; do not present the acknowledgement alone as a completed conversation.

9. Hand over to a real staff member

Actual recorded customer interface

Authenticate the staff connection separately. Send:

{
  "event": "takeover-chat",
  "body": {
    "chatId": "THE_CUSTOMER_CHAT_ID",
    "customerEmail": "THE_CUSTOMER_EMAIL",
    "message": "Optional first staff message"
  }
}

AppEngine records the takeover and emits agent-assigned. Subsequent customer messages should route to that person. A staff reply uses sendMessage with the intended chatId, customer to, content and type: "user"; the authenticated session supplies the sender.

The actual Studio Agent Desk can open the conversation and send the reply. A recorded UI inconsistency appears after a previous takeover and return to AI: the AI tab lists the new AI answer, but opening the thread can still say Assigned to you and hide Takeover. The course demonstrates the explicit takeover API and the real reply result; it does not edit app code or pretend the missing button was clicked.

Keep the customer screen visible long enough to see the staff's name and reply arrive. The customer receives an assigned person, not that person's credential.

10. Reload and preserve the conversation

Actual recorded customer interface

Retain the returned chatSessionId and include it in auth.context on reconnect. The course host stores it under the normalized customer email and organization. After reloading, sign in again, reconnect, and load history.

A retained human handoff should be visible in the new authenticate result and in the recipient of the next customer message. The controlled API proof verifies that follow-up with routedTo: "agent". Reconcile messages by their IDs so an optimistic send and its saved record do not render twice.

These tests concern controlled participants and their own threads. They do not certify that every gateway route rejects every unrelated chatId. Do not generalize the tested result into an unverified ownership guarantee.

11. Typing, read status and attachments

Actual recorded customer interface

The official controller emits typing state through shareStatus, and uses message-status events for read updates. Typing, acceptance and reading are different signals; a user starting to type is not a saved message, and a sent message is not necessarily read.

The host adds a real FilePicker callback through onPickAttachments. The unchanged chat controller performs its upload and sends the returned file information. The HTTP operation is POST /chat/upload/{configId}/{encodedCustomerEmail}, with organization header and multipart files. Let the HTTP client set the multipart boundary. The tested source route is public; do not claim that this path itself establishes authenticated file ownership.

The returned array contains file name, URL, path and source name. Preserve the durable path alongside any temporary display URL. Verify that the staff recipient and reopened message history contain the attachment. Upload success without a saved message is incomplete evidence.

Exact status event payloads

{"event":"shareStatus","data":{"from":"CUSTOMER_EMAIL","to":"ASSIGNED_STAFF_EMAIL","status":"typing","type":"typing"}}
{"event":"updateMessageStatus","data":{"uid":"DISPLAYED_SERVER_MESSAGE_ID","status":"read","from":"CUSTOMER_EMAIL","to":"ASSIGNED_STAFF_EMAIL"}}

The read event uses uid, the server message identifier. It does not accept an invented messageId field in its place. Both status operations were executed between the controlled customer and staff member. The staff recipient received status for typing and update for the read receipt. A separate Repository read verified the same message identifier had data.status: "read"; the history response does not expose status, so it cannot establish that persisted value by itself. The attachment has separate actual recipient/history evidence.

12. Waiting customers and staff availability

Exact integration context and current API contract

When neither an assistant nor a current staff assignment handles a customer message, the message can enter the queue. queued and queue-update provide actual position information. queue-position checks the intended conversation. Staff set-status changes availability; staff queue-stats provides queue metrics.

pick-next is an agent action and can claim another waiting entry. In a training organization, inspect the queue first and proceed only when it contains the isolated course entry. Do not run an unattended pickup script against a shared production queue. The configured agents direct alerts; the picker is not proven to restrict selection to that configuration's agent list.

Once an agent is assigned, verify the customer receives agent-assigned and that the next message reaches the selected person. The explicit request examples preserve the actual event names and payloads; they do not fake an HTTP queue endpoint.

Configure and execute the isolated queue procedure

Use a separate training chat configuration for this check. Reuse the create/read procedure above, but choose a unique name, set ai to an empty string, list the controlled staff account in agents, and keep missedChat.enabled false. Save its returned sk as the queue configuration ID. Connect a controlled customer who has no existing staff assignment using that ID; the assistant-enabled configuration from the earlier lesson will answer rather than enter this queue.

Register customer listeners for queued, queue-update and agent-assigned before connecting. Use a separate staff socket. Both sockets must have completed authenticate.success before the following calls. These are Socket.IO event names, not HTTP paths.

ActorEvent and exact payloadCheck before continuing
Staffqueue-stats, {}success:true and stats.size:0; otherwise stop the isolated exercise.
Staffset-status, {"status":"available"}Successful acknowledgement.
Customerchat-message, {"message":"Controlled queue check: connect me to the integration support team.","chatSessionId":"RETURNED_CUSTOMER_SESSION_ID"}ACK routedTo:"queued"; retain messageId.
Customerqueue-position, {"chatId":"customer::YOUR_CONTROLLED_CUSTOMER_EMAIL"}position:1, ahead:0; use the authenticated email.
Staffqueue-stats, {}Exactly one waiting entry belonging to this exercise.
Staffpick-next, {}Returned chat.chatId and chat.customerEmail match that controlled participant.
CustomerReceive agent-assignedReturned chatId matches the same conversation and agent identifies the staff member.

For example, staff.timeout(15000).emit("pick-next", {}, (error, result) => { /* inspect result */ }); performs the staff operation after the checks. Python uses staff.call("pick-next", {}, namespace="/chat", timeout=15). On a timeout, inspect the assignment and history before repeating the mutation.

The captured queue proof began at zero, placed one controlled customer at position one, and returned that same customer's chat from pick-next; the customer received agent-assigned. The narration's queue illustration explains these state changes. The course also records the actual Studio waiting entry and Pick next action; the concept animation explains the surrounding state changes. After assignment, verify a new customer message reaches the staff member, then close the controlled exercise deliberately without requesting a transcript.

13. Return to AI and close intentionally

Actual recorded customer interface

A staff member can call resume-ai with chatId, customerEmail and the exact assistant name. Verify a new customer question gets a completed answer for its own newly acknowledged message ID. A late answer from an earlier turn does not prove the resumed question completed.

Close with close-chat and sendTranscript: false for this rehearsal. The actual close event identifies closedBy and closedByRole; the request's optional reason is not a demonstrated round-tripped field. This course does not request or claim delivery of an email transcript. Stored history remains readable in the tested thread; closing is not deletion.

14. Website guest sessions

The website route needs an app identity configured in the organization. Obtain that prerequisite through the platform's approved app configuration process; do not substitute a staff username simply because a permissive route accepts it.

POST /profile/guest/auth uses the orgid and appId headers with visitor email/name or phone. The current implementation reads appId and verifies a matching organization user; the controller's advertised appKey is not read by this implementation. The result provides the guest token. The chatSessionId comes from the subsequent socket authenticate event, not the HTTP guest-auth response.

Register listeners, connect with the guest token, retain the authenticate session ID, and pass it in context on reconnect. Guest sends and history must retain the same session context. The visitor-entered email is an asserted visitor identity, not verified email ownership.

The current official Flutter controller assumes the signed-in thread key. Use the separate browser integration for the guest formula instead of passing it a guest token unchanged. The guest browser procedure was executed using an existing authorized app identity in the demo organization and a new isolated course assistant/configuration. No new application credential was created. A question received a matching acknowledgement and stream-end result; after reload, authenticate returned the same guest session ID and history returned the exact saved answer. The older legacy application row is not listed by the current App Credentials UI, which filters for the newer app user type. Obtain your existing application identifier from your organization administrator or the authorized application configuration; do not substitute a staff token, API key, or another organization’s identifier.

Actual guest answer in the supplied browser client

Actual reconnected guest conversation with its saved answer

Automatic AI-to-human handoff

Manual takeover lets staff intervene. Automatic handoff lets the assistant act when a visitor asks for a person. Configure an explicit tool allowlist and an instruction that names the trigger. On the dedicated training assistant, use PUT /crm/ai-assistant/{assistantRecordId} with the staff token and organization header. Keep its existing factual instructions and append the handoff instruction; the tools and behaviorRules arrays are replaced by the values you send.

{
  "tools": [{"key": "transfer_chat_to_agent", "enabled": true}],
  "behaviorRules": [
    "Keep your existing factual instructions here.",
    "When the visitor asks for a human, invoke transfer_chat_to_agent with reason and summary. Use priority 0. Report its actual queue position."
  ]
}

This is a configuration pattern; the download's http/json/assistant-handoff.json supplies the complete executed training instructions. Set APPENGINE_ASSISTANT_ID to the saved assistant record ID and run node http/client.mjs assistant-enable-handoff, or the matching TypeScript, Python, curl or Postman operation. Do not confuse the HTTP record ID with the assistant name used by socket resume-ai.

  1. Keep the guest connected to the chat configuration that selects this assistant. Ask Please connect me to a human support agent. Wait for the corresponding response to finish.
  2. In the controlled staff session, inspect queue-stats and queue-position for this exact guest thread. The demonstrated queue changed from zero entries to one; the guest was first.
  3. Use pick-next only for that isolated intended queue. Inspect its returned chat.chatId, then chat.context.reason, summary, aiHandoff and assistantId. In this execution, aiHandoff was true and the summary carried the earlier package question plus the request for a person.
  4. Send a staff reply through sendMessage with the exact chatId, guest email and content. The same browser guest receives the reply. Reopen history to confirm that reply is saved in this guest thread.

Actual guest asks for a person and receives the assistant's queue response

The same guest receives the staff reply

The source bundle includes a one-operation staff runner in TypeScript, Node and Python. Its pick-next guard requires an explicitly confirmed target, queue size one and target position one, then checks the returned identifier. These checks are not an atomic reservation; do not use this training runner to claim the next visitor in a busy production queue.

For live chat, this tool creates a queue entry. For email and social channels, the current tool follows a ticket escalation path. Those channels need their own configured delivery and staff workflow; a successful web-chat queue test does not establish external-channel delivery.

15. Leave an offline request and find the saved support ticket

When the widget is away, the visitor can leave a question for the team. This is a separate guest-ticket operation: it does not require an open Socket.IO conversation, and a registered customer's token is not interchangeable with its guest identity. The official widget now demonstrates the whole path from the away form to the actual saved ticket in Studio.

Prepare the widget and its app identity

Use the same organization and saved chat configuration throughout. Your organization needs an existing App ID for guest authentication. Find it in Configuration → User Management → More → App Credentials. If your organization does not have one, a staff member with ConfigAdmin can create it; API Foundations, Connect your real app covers that credential setup. Creating an app identity here does not require a root organization. The widget's guest-authentication request uses the public App ID, not its app secret. Keep any secret in authorized server tooling.

In Studio, open Chat Designer and choose the intended saved widget in its widget picker. Set Status to Offline for this exercise. Expand Offline, turn on Show Form, and enter a useful Message, such as “Leave your question and our team will follow up.” Choose Save and wait for Chat configuration saved successfully. Check the selected widget before changing it: do not put your live support widget offline just to follow the course. The configured outside-hours state can also show the form; the recorded exercise explicitly sets this one widget offline.

Open the website that loads that widget. Choose Leave us a message. The form displays We are offline and the message you saved. It contains required Name* and Email*, optional Phone number, Message and Submit. The current widget supplies a generic “Chat request” if Message is blank; enter the actual support question so staff has useful context.

Actual website shows the enabled offline form with Name, Email, optional Phone number, Message and Submit

Submit once, then inspect the exact staff record

  1. Enter a name and email you control. In the recorded example the visitor is Maya Chen. Leave the optional phone blank and enter Where do I set the chat configuration ID in my Flutter app?
  2. Choose Submit once and let the request finish. The button shows its loading state while the widget authenticates the entered visitor details and creates the ticket. Do not close the form or submit repeatedly while it is still waiting.
  3. Wait for Your message has been sent. This is the widget's request confirmation. It is not proof that an email or SMS reached anyone, and the accompanying “We will get back to you shortly” text is not a measured response-time guarantee.
  4. Switch to your authorized staff session, open Support Tickets, find Chat request from Maya Chen, and open it. Match the visitor, full question and ticket identifier to the submission. The recorded ticket is New, with Medium priority and Chat channel. Its workflow panel shows the configured ticket-support process.
  5. Reopen the same ticket and confirm the question remains saved. Creating a new ticket is not how to refresh this result. If a submission response is uncertain, inspect the existing record before retrying and producing another request.

Actual widget confirmation after the server returned a saved ticket

Actual staff ticket contains the recorded visitor question, New status, Medium priority and Chat channel

Implement the same route in your own client

The official widget authenticates the current entered visitor details afresh, rather than silently reusing another visitor's cached token. Follow the guest-authentication procedure in the earlier guest-session chapter: POST /profile/guest/auth with the correct orgid, public appId, name and email, then retain the returned guest token privately for this operation. The supplied email remains visitor-asserted, not verified account ownership.

Submit POST /crm/tickets/chat-request with the same organization header, Authorization: Bearer <guest token>, and the minimal body:

{
  "data": {
    "description": "Where do I set the chat configuration ID in my Flutter app?",
    "configId": "YOUR_SAVED_CHAT_CONFIG_ID"
  }
}

The server checks that the token represents a guest in that organization and the selected widget allows its away form. It derives the reporter's name/email/phone from that guest and fixes the initial source/status/priority. Do not use arbitrary caller-supplied assignment, ownership or priority fields to control this public intake. If you include reportedByEmail, it must match the guest session.

Only clear the form or show success after a successful response includes the saved ticket sk. Preserve the entered question on error. An expired guest token, wrong organization or widget with Show Form off is a real failure; handle its returned error instead of showing the success view. Staff verification belongs in the authorized staff context. Never pass that staff token to the visitor merely to check a ticket.

The download now includes offline-ticket-create in the TypeScript, JavaScript, Python, curl and Postman HTTP examples, plus http/json/offline-ticket.json. Set APPENGINE_GUEST_TOKEN to the fresh guest token for these details, APPENGINE_CHAT_CONFIG to this saved widget and APPENGINE_MESSAGE to the visitor's question. Use one implementation, not all of them against the same submission:

node http/client.mjs offline-ticket-create
# Alternatives: python3 http/client.py offline-ticket-create
# From node/: npm run http -- offline-ticket-create
# From http/: sh requests.sh offline-ticket-create

Each create can start the organization's configured ticket workflow and queue a ticket-created notification. The recorded proof used a local mail sink and disabled external SMS; it establishes guest submission, saved ticket and staff readback, not external notification delivery. The CRM APIs course covers broader customer-ticket intake and staff processing.

Keep assistant handoff and offline intake distinct

The earlier guest assistant has transfer_chat_to_agent enabled; its live queue context contains the handoff reason and summary, and the staff reply returns in that same chat. Offline intake creates a separate support ticket from the away form. The chat stream did not expose a separate tool-use/tool-result event, so that event is not part of the demonstrated contract. The earlier basic assistant has tools: [] and cannot execute this transfer tool. Staff assist-ai can supply private additional context before an AI reply, while resume-ai changes who handles later messages. Choose and test the route needed by your interface instead of treating every chat as all of these operations at once.

16. Reproduce the flow from another client

Exact integration context and current API contract

The Node and TypeScript examples use socket.io-client; Python uses python-socketio. They install authentication, message, stream and assignment listeners before connect. Both select the tested WebSocket transport. The HTTP setup examples include complete JSON, curl and Postman operations. Socket events remain labelled as Socket.IO.

Do not repeat generic programming setup during the support procedure. Install the named dependencies using the included manifests, supply your actual environment values, and follow the same configuration → identity → connection → message → saved outcome sequence. Use separate secrets for customer and staff processes.

17. Recover without manufacturing success

Exact integration context and current API contract

If authenticate fails, show its reason and obtain a valid session before enabling Send. If an acknowledgement fails or times out, inspect history before blindly resending an uncertain message. If AI produces fallback content, do not label that a useful answer simply because an end event occurred. Preserve the user's typed question and offer an intentional retry or the configured human route.

A reconnect test must include a new message reaching the intended person, not merely the old transcript reappearing. An attachment test must include its saved message. A configuration test must show the selected saved record and its actual runtime behavior.

Source, downloads and verification scope

The maintained companion source is now in appmint-examples: Real-time Support, published in commit c9ac54ebfd6b54c875215176a4b599531fb3cfcb. Open the Flutter host, browser client, Node/TypeScript clients, Python clients, or HTTP and offline-ticket examples. The repository README contains portable installation instructions and the required sibling appmint-client dependency. The Flutter demonstration is a web build; native Android/iOS installation is not claimed. The ZIP below remains a course snapshot.

The lab contains exact sanitized requests/results for assistant/config setup and two fully correlated AI turns, controlled takeover, reconnect routing, resume and retained history. The recorded Flutter customer has separate saved-history proof. Attachment delivery and history use the same server message ID and durable path; the queue check began empty and picked only the controlled customer. The final resumed turn has a matching acknowledgement messageId, stream-end ID and saved answer. The supplied browser client now has actual signed-in question/answer, staff handoff and guest reconnect proof. Typing/read recipient events and persisted read state were also verified. Offline intake has now been executed through the official widget: fresh guest authentication, submitted visitor question, returned saved ticket and matching staff readback. The recorded staff view confirms the exact question and ticket state. Mail was routed to the local sink and external SMS was disabled; external delivery is not claimed. The revised video is ready for review: 13:09, 21 chapters and 1440p. Independent encoded visual/audio checks and every chapter seek passed; this status is a review handoff, not learner acceptance.

Download the complete source · Watch the chaptered course.

AppEngine Realtime chat, presence and workspace APIs — supporting guide

Companion source code

Course examples and setup on GitHub. This module shares its companion with the linked consolidated course.

Deliverable: written developer lesson.

Why realtime, and what AppEngine does about it

A customer on your website wants an answer now, not an email tomorrow. Someone on your team has to see the question the moment it arrives, reply, and hand the follow-up to a colleague who also sees it straight away. Building that yourself means a socket server, a waiting queue, agent availability, message storage, AI replies, reconnect handling and inactivity timeouts.

AppEngine already runs all of it. When a customer's message arrives, AppEngine saves it, decides whether the AI assistant, an assigned agent or the waiting queue gets it, tells the right staff, streams the AI's answer back, and closes the chat if everyone goes quiet. Your app connects, sends events and draws what comes back.

Throughout this course you'll build one thing: live support for a bike repair shop. Maya, a customer, asks "Is my bike ready?" from the shop's website. Sam, on the shop's team, picks the chat up and answers. Sam then posts a follow-up for the workshop in a team workspace, and the workshop's screen updates without a refresh. At the end, a supervisor watches the shop's AI phone line live.

Before you start, complete AppEngine API foundations. You'll need an organization, one staff account and one customer account from it.

1. What is built in

The connection: Socket.IO namespaces

Realtime traffic uses Socket.IO, a library that keeps one connection open between your app and the server and lets either side send named events with a JSON body. Your client must be a Socket.IO client (socket.io-client 4.x on the web); a raw browser WebSocket can't speak it.

A namespace is a path on that connection that groups related events. AppEngine has three for this course:

NamespaceWho connectsWhat it carries
/chatcustomers, website visitors and stafflive support conversations
/workspacestaff and customers who are membersteam workspace changes
/live-callsstaff with permissionphone calls in progress, live transcript and audio

Every connection sends a token (the signed login ticket AppEngine returns when a person signs in, also called a JWT) and the organization ID (orgId, the ID of the business account all records belong to). AppEngine reads who the person is from the token itself, so a customer can never claim to be staff.

Built-in data types

A data type is a record type AppEngine already defines, with its fields. These are the ones realtime uses:

Data typeWhat it holdsFields you'll meet
chat_configone chat widget's setupname, status (online/offline), ai (the AI assistant that answers first), agents[].user (staff emails), offline.showForm, missedChat, defaultPath
chat_messageone message in a conversationchatId, from, to, content, type, files, status, sentTime
chat_groupa staff group chat of three or more peoplename, emails, admins
customerthe person chatting from your siteemail, name, phone
ticketan "away" request when nobody is onlinetitle, description, reportedByEmail, channel, priority, status
workspacea team space or conversationtitle, type (workspace/conversation), isPrivate, members[] (email, accessType, lastReadAt)
workspace_itemone entry in a workspace's journaltype (message, task, file, event…), message, files, room, parentItem

Every saved record has an sk, its unique ID, and keeps its fields under data. Chat messages and workspace items are created for you by the actions below; you rarely write them directly.

Built-in actions

On /chat (events you emit; the reply comes back in the event's acknowledgement, a callback Socket.IO runs with the server's answer):

You sendAppEngine does for you
connect with auth: {token, orgId, configId}checks the token belongs to this organization, works out customer or staff, loads the widget's chat config, resumes the customer's conversation, sends the AI greeting for a new chat, then emits authenticate
chat-message (customer)saves the message, then routes it: to the assigned agent, or to the configured AI assistant (streaming the answer), or into the waiting queue with position and estimated wait; alerts the widget's agents; arms the missed-chat email and inactivity timeouts
set-status (staff)marks the agent available, busy or away and tells the rest of the team
pick-next (staff)takes the longest-waiting customer off the queue, assigns them to you, posts "Sam has joined the chat", tells the customer, removes the entry from every other agent's list
sendMessage (staff)saves the reply in the conversation and delivers it to the customer's open tabs and devices
takeover-chat / assist-ai / resume-ai (staff)stops the AI and makes you the handler / gives the AI a private instruction and lets it answer / hands the chat back to the AI
transfer-chat (staff)moves the chat (or adds a second agent) and tells both agents and the customer
close-chatends the chat for everyone, records handle time, and can email the transcript
chat-history / list-chatsreturns the latest 100 saved messages / the person's conversations

Over HTTP (Authorization: Bearer <token> and an orgid header, as in the foundations course):

You callAppEngine does for you
GET chat/config/:configIdreturns the widget's saved chat_config (public; orgid only)
POST profile/guest/authsigns in a website visitor by email or phone, creating a customer record the first time
POST chat/upload/:folder/:emailstores attachments and returns their links for the next message
POST crm/tickets/chat-requestturns an "away" message into a ticket when no one is online
GET chat/queue, GET chat/queue/stats, GET chat/agents/online, GET chat/ai-activethe waiting list, queue size and average wait, who's available, and which chats the AI is handling now — for a staff dashboard
POST chat/transcript/sendemails a conversation's transcript to an address

Here is one real exchange. Maya, signed in, sends her first message on a widget with no AI assistant and nobody yet assigned:

socket.emit('chat-message', { message: 'Is my bike ready?' }, (ack) => console.log(ack));
{ "success": true, "messageId": "6704a1f2c3d4e5f6a7b8c9d0", "chatId": "customer::[email protected]", "routedTo": "queued" }

and, on the same socket, the server also emits:

queued → { "position": 1, "ahead": 0, "estimatedWaitSeconds": 120, "message": "You are in the queue. An agent will be with you shortly." }

Every agent listed on the widget's chat config receives a queue-notification with Maya's email and the start of her message. You wrote none of that.

The rule

  1. Use the built-in action. Send chat-message, pick-next, sendMessage. If you instead save a chat_message record yourself, nobody is alerted, nothing is queued, the AI never answers and no timeout runs.
  2. Use the built-in data type for the thing itself. Add your own fields to chat_config or customer (your organization's fields are merged over the built-in ones) rather than inventing a my_chat_settings collection.
  3. Create your own collection only for something AppEngine doesn't have — say, bike_repair_job — and read and write it with the generic Repository routes in Data records.

Or use the ready-made clients

If the standard chat window is all you need, you don't need to write a client at all. In Studio, open CRM → Chat Designer, create the widget, save it, and copy the snippet from the Embed Code tab:

<link type="text/css" rel="stylesheet" href="https://web.appmint.space/chat-client/static/css/main.css">
<div id="appmint-chat-container"></div>
<script>
  function initializeChat() {
    AppmintChat.init({
      containerId: 'appmint-chat-container',
      orgId: 'YOUR_ORG_ID',
      configId: 'YOUR_CHAT_CONFIG_ID',
      appId: 'YOUR_APP_USERNAME',
      theme: 'light',
      language: 'en'
    });
  }
</script>
<script src="https://web.appmint.space/chat-client/appmint-chat.js" onload="initializeChat()"></script>

configId is the chat config's sk; appId is the username of the app user the widget signs visitors in through. Neither is a secret. Add apiHost: 'https://your-appengine-host' when your AppEngine isn't the hosted one.

For a Flutter app there is appmint_flutter_chat in the appmint-client repository (add it as a path dependency; it isn't on pub.dev). Show AppmintSupportChatScreen(config: AppmintChatConfig(endpoint: …, orgId: …, user: AppmintChatUser(email: …), token: () async => customerToken, configId: …)), and pass loadSessionId / saveSessionId so the conversation resumes when the app reopens. It is built for signed-in customers.

The rest of this course is for when you build your own client — a branded chat in your app, a custom agent desk, or a dashboard. Both ready-made clients use exactly the events below.

2. Set up the shop's chat

You need three things before connecting.

A chat config. In CRM → Chat Designer, create a widget for the shop, add Sam under the agents section, and leave the AI assistant empty for now so the first chat goes to a person. Save it and note its ID (shown at the top of the designer). Read it back to check what AppEngine will use:

curl -s "$API/chat/config/$CONFIG_ID" -H "orgid: $ORG"
{ "sk": "6703f0a1b2c3d4e5f6a7b8c9", "datatype": "chat_config",
  "data": { "name": "shop-support", "status": "online", "agents": [{ "user": "[email protected]" }], "offline": { "showForm": true } } }

Sam's token. Sign in as staff with POST profile/signin ({email, password}) and keep the returned token.

Maya's token. Sign her in as a customer with POST profile/customer/signin, or, for a visitor who hasn't got an account, use guest sign-in (section 3). Both requests are walked through in API foundations.

3. Connect Maya

Open the /chat namespace with her token, the organization and the widget's config, and wait for the server's authenticate event before sending anything:

import { io } from 'socket.io-client';

const saved = localStorage.getItem(`chat:${ORG}:${MAYA_EMAIL}`); // her last chatSessionId, if any

const socket = io(`${API}/chat`, {
  transports: ['websocket'],
  auth: { token: MAYA_TOKEN, orgId: ORG, configId: CONFIG_ID, context: { chatSessionId: saved } },
});

socket.on('authenticate', (result) => {
  if (!result.success) return showSignInProblem(result.error); // token_expired, invalid_token, organization_mismatch…
  localStorage.setItem(`chat:${ORG}:${MAYA_EMAIL}`, result.chatSessionId);
  startChat(result);
});
{ "success": true,
  "user": { "email": "[email protected]", "name": "Maya Ortiz", "role": "customer" },
  "chatSessionId": "cs_lx2k9a_7f3m1q8z", "isGuest": false,
  "config": { "configId": "6703f0a1b2c3d4e5f6a7b8c9", "agents": [{ "user": "[email protected]" }], "status": "online" } }

Two IDs come back, and they mean different things:

  • chatSessionId identifies this visit. Save it and send it as context.chatSessionId next time, and AppEngine puts Maya back in the same conversation (no second greeting, same agent).
  • The conversation ID (chatId) is the key every message in the thread is stored under. AppEngine builds it from who she is: customer::[email protected] for a signed-in customer, customer:{chatSessionId}:{email} for a guest. Use exactly that value; your client never makes one up.

If Maya was already being helped by Sam when she reconnects, the result also carries handoff: { chatId, agentEmail }, and her next message goes straight to Sam.

Visitors without an account. The widget signs visitors in as guests. Do the same with the app user's username as appId:

curl -s -X POST "$API/profile/guest/auth" -H "orgid: $ORG" -H "appId: $APP_USERNAME" \
  -H "Content-Type: application/json" -d '{"email":"[email protected]","name":"Maya Ortiz"}'

It returns { guest, token, refreshToken }; connect with that token. A guest's conversation lasts while the visit is active: AppEngine keeps a guest session for 30 minutes after the last activity, warns with session-expiring in the last two minutes, and emits session-expired when it's gone. When sending as a guest, include chatSessionId in each chat-message so AppEngine can find the session.

4. Maya asks her question

socket.emit('chat-message', { message: 'Is my bike ready?' }, (ack) => {
  if (!ack?.success) return markFailed(localId, ack?.error);
  confirmSent(localId, ack.messageId);       // swap your temporary row for the saved one
  if (ack.routedTo === 'queued') showWaiting();
});

socket.on('queued', (q) => showQueuePosition(q.position, q.estimatedWaitSeconds));
socket.on('queue-update', (q) => showQueuePosition(q.position, q.estimatedWaitSeconds));

Show her message straight away with a temporary ID, then replace that ID with messageId from the acknowledgement. The acknowledgement tells you the message is saved and where it went: routedTo: 'queued' (waiting for an agent), routedTo: 'agent' (delivered to the assigned agent) or, when the widget has an AI assistant, assistantId (the AI is answering — section 7).

If the acknowledgement doesn't come back in about 12 seconds, don't resend blindly: reload the history (section 6) and check whether the message is already there.

5. Sam picks it up and replies

Sam's agent desk opens its own /chat connection with Sam's token. Because the token belongs to a staff user, AppEngine treats this socket as an agent and sends him every queue-notification for the widgets he's listed on.

const desk = io(`${API}/chat`, { transports: ['websocket'], auth: { token: SAM_TOKEN, orgId: ORG } });

desk.on('authenticate', (r) => {
  if (!r.success) return showSignInProblem(r.error);
  desk.emit('set-status', { status: 'available' }, () => {});
});

desk.on('queue-notification', (n) => addToWaitingList(n));   // { chatId, customerEmail, customerName, message, position }

Sam clicks Take next:

desk.emit('pick-next', {}, (r) => {
  if (!r.chat) return showNothingWaiting();
  openConversation(r.chat.chatId, r.chat.customerEmail);   // waitedSeconds tells you how long she waited
});

AppEngine assigns Maya to Sam, joins them both to the conversation, saves and sends "Sam has joined the chat.", emits agent-assigned to Maya with agent: { email, name }, and starts the inactivity clock: if nobody writes for 4 minutes, both sides get "Are you still there?", and after 5 minutes of silence the chat closes. Any message restarts the clock.

Sam replies with sendMessage, naming the conversation and the customer:

desk.emit('sendMessage', {
  chatId: 'customer::[email protected]',
  to: '[email protected]',
  content: "Hi Maya, it's ready — the new chain is on. We're open until 6.",
  type: 'user',
}, (r) => r.success ? confirmSent(localId, r.messageId) : markFailed(localId, r.error));

Always send chatId from a customer conversation. Without it, sendMessage treats the message as a staff direct message and starts a different thread.

6. Maya sees the reply, and gets it back after a reconnect

On Maya's socket the reply arrives as a message event:

{ "chatId": "customer::[email protected]", "from": "[email protected]", "to": "[email protected]",
  "content": "Hi Maya, it's ready — the new chain is on. We're open until 6.", "type": "user",
  "senderRole": "agent", "status": "sent", "sentTime": 1728381234567,
  "messageId": "6704a2b7c3d4e5f6a7b8c9e1", "data": { "...": "the same fields" } }

The fields are sent both flat and under data, so either style of client can read them. Add the message to the thread by messageId; if you already have that ID, skip it.

Phones sleep and networks drop. Socket.IO reconnects by itself and AppEngine authenticates again from the same auth, so after every authenticate success, reload the conversation from storage and merge it with what's on screen:

socket.emit('chat-history', { chatId, chatSessionId }, (r) => {
  if (r.success) mergeById(r.history);   // oldest first
});
{ "success": true, "history": [
  { "id": "6704a1f2c3d4e5f6a7b8c9d0", "from": "[email protected]", "to": "queued", "content": "Is my bike ready?", "type": "user", "senderRole": "customer", "timestamp": "2026-10-08T09:12:01.000Z" },
  { "id": "6704a2b7c3d4e5f6a7b8c9e0", "from": "system", "content": "Sam has joined the chat.", "type": "system" },
  { "id": "6704a2b7c3d4e5f6a7b8c9e1", "from": "[email protected]", "content": "Hi Maya, it's ready — …", "type": "user", "senderRole": "agent" } ] }

chat-history returns the latest 100 messages. To show a list of past conversations (for example a "Previous chats" screen), emit list-chats with { filter: 'active' | 'archived' | 'all', search }.

Socket.IO reuses the auth object on every automatic reconnect, so an expired token stays expired. When you get authenticate with error: 'token_expired' (or a token_expired event mid-session), refresh the token through your normal sign-in flow, set socket.auth.token to the new one, and call socket.connect(). For a guest, call guest sign-in again.

Small touches. shareStatus with { to, status: 'typing' } shows "Sam is typing…" on the other side as a status event. updateMessageStatus with { uid: messageId, status: 'read', from, to } stores the read state on the message and tells the sender with an update event.

7. When the AI assistant answers first

Now go back to the Chat Designer and pick an AI assistant for the widget (set one up first in AI Employee APIs). New conversations get a greeting from it, and Maya's messages go to the AI instead of the queue. Her acknowledgement says assistantId instead of routedTo, and the answer streams in as chat-stream events, all with the same messageId:

socket.on('chat-stream', (s) => {
  if (s.event === 'chunk') appendToDraft(`ai-${s.messageId}`, s.data);
  if (s.event === 'tool-use') showWorking(s.tool);           // e.g. the AI is looking up her repair
  if (s.event === 'end') finishDraft(`ai-${s.messageId}`, s.sk); // s.sk = the saved answer's ID
  if (s.event === 'error') failDraft(`ai-${s.messageId}`, s.error);
});

The stream reuses the customer message's messageId, so keep the AI's draft under its own key (here ai-…); otherwise the answer replaces Maya's question on screen. When end arrives, give the draft the saved ID sk. If the connection drops mid-answer, drop the draft and reload chat-history, which has the finished answer.

From the agent desk, Sam can step in at any point:

Sam sendsWhat happens
takeover-chat { chatId, customerEmail, message? }the AI's answer stops, Sam becomes the handler, Maya sees "Sam has taken over the conversation" and agent-assigned; her next messages go to Sam
assist-ai { chatId, instruction }the AI gets a private instruction ("offer a free safety check") and writes the next answer; Maya sees only the answer
resume-ai { chatId, customerEmail }hands the chat back to the AI; Maya sees "You are now chatting with our AI assistant."
transfer-chat { chatId, toAgentEmail, note, keepInRoom }passes Maya to another online agent (or adds them with keepInRoom: true); the new agent gets chat-transferred with the note, Maya gets agent-changed

GET chat/ai-active lists the conversations the AI is handling right now, which is what an agent desk uses to offer Take over. For a full walk-through of takeover with two live sockets, see Build realtime support.

8. End the chat, attachments and away messages

Ending. Either side sends close-chat { chatId, sendTranscript: true }. Everyone in the conversation receives chat-ended { chatId, closedBy, closedByRole }, and Maya gets the transcript by email. If both sides go quiet, AppEngine closes it for you with reason: 'inactivity'.

Attachments. Upload first, then send the returned file details with the message:

curl -s -X POST "$API/chat/upload/$CONFIG_ID/maya%40example.com" -H "orgid: $ORG" \
  -H "Authorization: Bearer $MAYA_TOKEN" -F "[email protected]"
[{ "name": "receipt.jpg", "url": "https://…signed link…", "path": "chat-files/6703f0a1…/files-2026-10-08T09-20-11.jpg", "sourceName": "receipt.jpg" }]
socket.emit('chat-message', { message: "Here's my receipt", files: uploaded }, onAck);

The url is a time-limited link; keep path if you store the file reference. When the AI answers, it sees attached images too.

Away messages. If the chat config's status is offline, or nobody answers, let the visitor leave a message as a ticket. Turn on show form in the widget's offline settings (offline.showForm), then send it with the visitor's guest token:

curl -s -X POST "$API/crm/tickets/chat-request" -H "orgid: $ORG" -H "Authorization: Bearer $GUEST_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"data":{"description":"Is my bike ready? I can collect after 5.","configId":"'"$CONFIG_ID"'"}}'

AppEngine fills the title, contact details, channel and priority from the guest's sign-in and returns the new ticket record; show success once you have its sk. Working the ticket is covered in CRM APIs.

9. Hand the follow-up to the workshop in a workspace

Sam promised Maya a free safety check. The workshop team works in a workspace: a shared space whose journal holds messages, tasks, files and meetings. Unlike chat, every entry is saved first through HTTP; the /workspace socket then tells every member's screen that something changed.

Sam creates a private workspace and adds the workshop lead:

curl -s -X POST "$API/workspace" -H "orgid: $ORG" -H "Authorization: Bearer $SAM_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"type":"workspace","title":"Workshop follow-ups","isPrivate":true,"members":[{"email":"[email protected]"}]}'
{ "id": "6704b0c1d2e3f4a5b6c7d8e9", "name": "workshop-follow-ups-k3p9", "title": "Workshop follow-ups", "type": "workspace",
  "isPrivate": true, "memberCount": 2, "isMember": true, "isAdmin": true,
  "members": [ { "email": "[email protected]", "accessType": "admin" }, { "email": "[email protected]", "accessType": "member" } ] }

AppEngine makes Sam the admin, emails Lee that he's been added, writes the membership into the journal, and pushes workspace.created to both. Keep the returned id.

Lee's screen connects to /workspace and waits for ready:

const ws = io(`${API}/workspace`, { transports: ['websocket'], auth: { token: LEE_TOKEN, orgId: ORG } });

ws.on('ready', () => reloadOpenWorkspace());     // after first connect and every reconnect
ws.on('item.created', (e) => addItem(e.workspaceId, e.item));
ws.on('item.updated', (e) => replaceItem(e.workspaceId, e.item));
ws.on('item.deleted', (e) => markDeleted(e.workspaceId, e));
ws.on('member.removed', (e) => leaveIfMe(e));

On connect, AppEngine joins Lee to every workspace he can read, so there's nothing to subscribe to. A workspace he's added to later joins automatically too; if your screen creates or opens one in another way, emit subscribe { workspaceId } and check for { ok: true }.

Sam posts the follow-up:

curl -s -X POST "$API/workspace/$WS_ID/item" -H "orgid: $ORG" -H "Authorization: Bearer $SAM_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"type":"message","message":"Maya Ortiz collecting after 5 today — please do the free safety check before then. @[email protected]"}'

AppEngine saves the workspace_item, notifies Lee of the @mention, and pushes to everyone in the workspace:

item.created → { "workspaceId": "6704b0c1d2e3f4a5b6c7d8e9",
  "item": { "id": "6704b1a2…", "type": "message", "message": "Maya Ortiz collecting after 5 today — …",
            "mentions": ["[email protected]"], "authorName": "Sam Patel", "createdate": "2026-10-08T09:25:40.000Z" } }

Send "type":"task" with assignTo instead and AppEngine also creates a real task assigned to Lee.

After a reconnect, the socket doesn't replay what you missed, so on ready read the journal again with GET workspace/:id/items (filters: type, room, thread, before, limit). Edit an entry with PATCH workspace/item/:iid. When Lee opens the workspace, POST workspace/:id/read stores his read position; the socket's typing { workspaceId } and read { workspaceId } events are only live hints to the others.

AppEngine re-checks every connected account every 15 seconds. If an account is locked, its socket receives account_locked and is closed; service_unavailable with retryable: true means the server couldn't check just then, so try that action again.

10. Supervise the AI phone line live

The shop also has an AI employee answering the phone (set up in Phone APIs and AI Employee APIs). A supervisor's screen connects to /live-calls with a staff token:

const live = io(`${API}/live-calls`, { transports: ['websocket'], auth: { token: SUPERVISOR_TOKEN, orgId: ORG } });

live.on('calls', (calls) => showCalls(calls));          // the full list, on connect
live.on('call.started', (call) => upsertCall(call));
live.on('call.ended', ({ callSid }) => removeCall(callSid));

Staff with the owner, configadmin, rootadmin or poweruser role see every call; anyone else sees the calls of AI employees they supervise. Each call looks like:

{ "callSid": "CA9f…", "from": "+15551230000", "to": "+15559870000", "direction": "inbound",
  "agent": { "kind": "employee", "id": "…", "name": "Riley", "supervisor": "[email protected]" },
  "startedAt": "2026-10-08T09:31:02.000Z" }
Supervisor sendsReplyWhat happens
listen { callSid }{ ok, call, lines }you start receiving line (transcript) and audio events for that call; lines is the transcript so far
whisper { callSid, text }{ ok }the AI hears your instruction; the caller doesn't
takeover { callSid }{ ok }the call is put through to your softphone, which must be open and signed in
hangup { callSid }{ ok }the call is ended
unlisten { callSid } / list{ ok } / { ok, calls }stop listening / fetch the current list

Transcript lines are { callSid, role: 'caller' | 'ai' | 'supervisor', text, at, by }. Audio events are { callSid, track: 'in' | 'out', payload }, where payload is base64 8 kHz mu-law (in is the caller, out is the AI); decode it into your audio player. { ok: true } means the instruction reached the server holding the call; watch for the call's next line, call.ended or your softphone ringing to see it happen.

Live calls are kept for 90 seconds at a time and renewed while the call runs, so a call whose server stops drops off on its own; ask for list every so often to keep the screen in step. After a reconnect, send listen again for the call you had open.

11. Find what else is there

You now know what you're looking for, so the API reference is quick to use. Open /documentation on your AppEngine host and pick the Chat tag for every chat route (queue, presence, sessions, transcript), Workspace for tasks, meetings, files and search inside a workspace, and Tickets for the away-message ticket. Socket events aren't listed there; this course is their reference. How the reference is organised is covered in API reference.

Where to go next