Developers · OmniChat

Put the AI chat inside your mobile app

Build your own native chat screen and let the AI bot and your team answer through OmniChat. It uses the web channel — the same one behind the website widget — so every message lands in your team's shared inbox like any other channel.
Base URL https://api.agenticos.tech/api/v1/omnichat

1. Overview

Mobile app
send / poll →
AgenticOS API
→ AI bot / your team
signed webhook → your server
FCM / APNs push → the app

Every endpoint the app calls is public and authorised by the embed token alone — no JWT. Three values are involved, each with its own job:

ValueWhere to find itKeep it in
Embed tokenOmniChat → Channels → your web channel → embed code (the data-omnichat-token value)The app (it is public)
Identity keySame page → “Connect your site's member login” (optional)Your server only
Webhook signing keySame page → “Mobile app notifications on reply” (optional)Your server only

Before you start: what to ask the platform for

These values and settings live in the customer organisation's AgenticOS account; an organisation admin (owner or admin) provides them. Collect them before you start coding.

Ask forUsed forWhere the admin finds itNeeded
A web channel with the bot set upSo the bot answers: the channel must be bound to a desk whose AI agent has auto-reply on and a knowledge base attachedOmniChat → Channels → Add channel → WebsiteYes
Embed tokenSent with every call from the appWeb channel → embed code → the data-omnichat-token valueYes
Identity keyTying chats to member accounts (2.2)Web channel → Connect your site's member login → Create keyIf the app has logins
Webhook signing keyVerifying the push webhook (section 4)Web channel → Mobile app notifications on reply → enter the URL the dev sent, then copy the keyIf you want push
Messages-per-minute capDefault 20 per token per IP — raise it if many users share one IP (office Wi-Fi, for example)OmniChat settings → Web chat protection → Message rate limitIf needed
Pre-chat form: on or offIf on, the app needs a form screen (3.5)Web channel → Require contact details before chattingGood to know
The identity key and signing key are secrets: have the admin share them through something safe (a password manager, not a group chat or plain email), and keep them on your server. The embed token is public and can be sent normally.
Request message (copy and send)
Hi — our app team is connecting the AgenticOS chat bot to our mobile app. Could you send:
1. The embed token of the web channel that has the bot set up (OmniChat → Channels → Website → embed code)
2. The identity key (web channel → Connect your site's member login → Create key) — via a password manager
3. Please enter this webhook URL: https://<our-server>/omnichat/reply and send back the signing key — via a password manager
4. Whether the pre-chat form is on, and please raise the message cap to __ per minute (if needed)

Shortcut: use a WebView (no chat screen to build)

If you don't need a native chat screen yet, open AgenticOS's hosted chat in a WebView and get every web-chat feature at once (knowledge-base answers, suggestion chips, photos, the pre-chat form, product cards, payment links). Two ways:

OptionWhat to loadMember identityBest for
A · Full-page chathttps://app.agenticos.tech/chat/<embed token>No — everyone is anonymousApps without logins, or the quickest start
B · Your HTML page + the inline widgetA tiny page your server renders with the user's user_id and user_hashYes — the chat follows the member across devices, and push worksApps with logins (recommended)

Option B: the page your server gives the WebView

Your server renders this per user, computing user_hash server-side (2.2); the app opens its URL in the WebView. The CSS makes the chat fill the screen (the inline widget is 480px tall by default).

HTML
<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style>
    html, body { margin: 0; height: 100%; }
    #omnichat-chat { height: 100%; }
    .ocw-inline { height: 100% !important; border: 0 !important; border-radius: 0 !important; }
  </style>
</head>
<body>
  <div id="omnichat-chat"></div>
  <script src="https://app.agenticos.tech/widget.js"
          data-omnichat-token="EMBED_TOKEN"
          data-omnichat-inline="omnichat-chat"
          data-omnichat-user-id="{{ user.id }}"
          data-omnichat-user-hash="{{ user_hash }}"
          data-omnichat-user-name="{{ user.name }}"
          async></script>
</body>
</html>
  • When the user signs out of the app, call omnichatEndSession() in the page (evaluateJavascript, for example) or clear the WebView's data before someone else signs in on the same device
  • Push only works with option B: the webhook carries user_id so your server can find the user's device. Option A has no identity to push to.

WebView settings the chat needs

WhatiOS (WKWebView)Android (WebView)
JavaScript and localStorage (holds the chat session)On by default; use WKWebsiteDataStore.default() so the session survives app restartsjavaScriptEnabled = true · domStorageEnabled = true
Sending photos and filesBuilt in; add NSPhotoLibraryUsageDescription and NSCameraUsageDescription to Info.plistImplement onShowFileChooser in your WebChromeClient
Payment links, attachments and outside links (open as _blank)Implement createWebViewWith in WKUIDelegate and open them with UIApplication.shared.openIn shouldOverrideUrlLoading, send hosts other than the chat to the external browser
Keyboard covering the inputUse the normal safe areaandroid:windowSoftInputMode="adjustResize"
Swift
// iOS — open _blank links (payment, attachments) outside the chat
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
             for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
    if let url = navigationAction.request.url { UIApplication.shared.open(url) }
    return nil
}
Kotlin
// Android
webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = true
webView.webViewClient = object : WebViewClient() {
    override fun shouldOverrideUrlLoading(view: WebView, req: WebResourceRequest): Boolean {
        val host = req.url.host ?: return false
        if (host.endsWith("agenticos.tech")) return false          // stay in the chat
        startActivity(Intent(Intent.ACTION_VIEW, req.url)); return true
    }
}
webView.webChromeClient = object : WebChromeClient() {
    override fun onShowFileChooser(view: WebView, cb: ValueCallback<Array<Uri>>,
                                   params: FileChooserParams): Boolean {
        filePathCallback = cb
        fileChooser.launch(params.createIntent())   // registerForActivityResult(StartActivityForResult)
        return true
    }
}
webView.loadUrl(chatPageUrl)

2. Who the user is

2.1 Anonymous

Generate a session_id once per install and keep it in the Keychain or EncryptedSharedPreferences.

  • Use a cryptographic random generator — the session_id is the only thing protecting an anonymous transcript.
  • 8–128 characters, and never starting with usr: (reserved — you'd get 400 reserved_session_id)
  • The website widget uses wsx- followed by 32 hex characters, e.g. wsx-1ecb99647586f8100cb4b7ca25d51ac9
  • Downside: reinstalling or changing phones starts a new conversation.

2.2 Tied to a member account (recommended)

Send user_id and user_hash as well. The conversation then belongs to the member, follows them across devices, and your team sees their real name.

Formula
user_hash = hex( HMAC-SHA256( key = identity key, message = user_id ) )   // lowercase hex, 64 chars
  • Compute it on your server only: the app asks your backend for user_hash after login. Never ship the identity key in the app.
  • A wrong or missing user_hash, or a channel without an identity key, is not an error: the user is treated as anonymous and talks in the session_id thread.
  • Keep sending session_id on every call.
Send the same user_id + user_hash when sending, uploading and polling. If poll leaves them out it reads the anonymous thread and never sees the replies in the member's thread.
Node.js
const userHash = crypto.createHmac("sha256", process.env.OMNICHAT_IDENTITY_KEY)
  .update(String(user.id)).digest("hex");
Python
user_hash = hmac.new(IDENTITY_KEY.encode(), str(user.id).encode(), hashlib.sha256).hexdigest()
PHP
$userHash = hash_hmac('sha256', (string)$user->id, OMNICHAT_IDENTITY_KEY);

3. Endpoints

Every endpoint also answers at /web/... in place of /webhooks/web/....

GET/webhooks/web/config

What the chat screen shows. Call it once when the screen opens, with ?token=<embed token>.

200
{
  "name": "Website chat",
  "avatarUrl": "https://…/avatar.png",
  "style": { "color": "#6366f1", "color2": "#22d3ee", "gradient": true, "title": "…", "placeholder": "…" },
  "prechat": null,
  "chatUrl": "https://app.agenticos.tech/chat/<token>",
  "suggestions": ["What can you do?", "Pricing", "Talk to our team"],
  "followup": true
}
keyUse
name avatarUrlThe bot's name and picture (avatarUrl may be null)
suggestionsSuggested-question chips, up to 6. Tapping one sends it as a normal message.
prechatThe pre-chat form, if enabled (see 3.5). null means none.
styleThe website widget's colours and copy — optional for an app.

An unknown token returns 200 with {"name": null, "avatarUrl": null, "style": null}, not 401.

POST/webhooks/web

Send a message (JSON).

request
{
  "token": "<embed token>",
  "session_id": "wsx-1ecb99647586f8100cb4b7ca25d51ac9",
  "text": "Machine 3 at the Lat Phrao branch is not working",
  "user_id": "member-42",
  "user_hash": "<64 hex>",
  "user_name": "Somying Jaidee",
  "user_phone": "0812345678",
  "user_email": "[email protected]"
}
fieldRequiredLimits
token✓8–200
session_id✓6–128 (8–128 recommended), not starting with usr:
text✓1–4000 characters
visitor_name≤120, display name for an anonymous user
user_id user_hash≤100, ≤64 — see 2.2
user_name user_email user_phone≤120, ≤200, ≤40 — used only with a valid user_hash
StatusbodyMeaning
200{"ok": true, "conversation_id": "<uuid>"}Accepted. The bot answers within 1–10 s — poll for it.
401{"ok": false}Unknown token, or the channel was removed
429{"ok": false, "error": "rate_limited"}Too many messages (see 5)
400{"ok": false, "error": "reserved_session_id"}session_id starts with usr:
409{"ok": false, "error": "prechat_required"}The pre-chat form must be sent first (3.5)
422{"detail": [...]}A field is malformed or too long (FastAPI's standard error)

GET/webhooks/web/poll

?token=…&session_id=…&after=<cursor>&user_id=…&user_hash=…

200
{
  "messages": [
    {
      "direction": "out",
      "sender_type": "ai",
      "text": "Hi! How can I help?",
      "created_at": "2026-09-30T08:15:02.418221+00:00",
      "attachment_url": null,
      "attachment_kind": null,
      "attachment_name": null,
      "card": null,
      "checkout_url": null
    }
  ],
  "cursor": "2026-09-30T08:15:02.418221+00:00",
  "prechat_done": true
}
Message keyValue
direction"in" from the user · "out" from the business
sender_type"customer" · "ai" (bot) · "human" (your team)
textThe text; may carry light Markdown such as **bold** and 1. lists
created_atISO-8601 with timezone
attachment_url attachment_kind attachment_nameAn attachment; kind is "image" or "file"
cardA product card {"kind": "products", "items": [...]}
checkout_urlA payment link found in the text, if any — render it as a button

Cursor rules

  • First call: leave after out to get the history, at most 50 messages per call, oldest first.
  • Then send after = the latest cursor, URL-encoded (it contains +).
  • No new messages: messages is empty and cursor echoes what you sent.
  • Got exactly 50: poll again straight away with the new cursor until fewer come back.
  • Messages have no id. To de-duplicate, key on created_at|direction|text|attachment_url, as the website widget does.
  • The user's own message also comes back (direction "in"); drop it with the key above if you already showed it optimistically.

Suggested polling rhythm

  • While the chat screen is open: every 4 s.
  • For 20 s after the user sends: every 1 s, with the first poll ~600 ms after sending.
  • App in the background: stop polling and rely on push (section 4).
  • There is no typing state in the API; show your own indicator after sending, for up to 30 s or until the reply arrives.

POST/webhooks/web/upload

Send a photo or file as multipart/form-data.

fieldRequiredNotes
token session_id✓
file✓Up to 20 MB, not empty, any type (image/* shows as a photo)
textCaption
user_id user_hashRequired when tied to a member

Success: 200 {"ok": true, "conversation_id": "<uuid>", "attachment_url": "<url>"} · errors: 401, 429, 400 reserved_session_id, 400 empty file, 413 file too large, 409 prechat_required

POST/webhooks/web/prechat

The pre-chat form, when config.prechat is not null. Members with a valid user_hash skip it.

request
{ "token": "…", "session_id": "…", "name": "Somying", "phone": "0812345678", "email": "", "company": "", "consent": true }
  • config.prechat.fields says whether each field is off, optional or required.
  • If config.prechat.consent_text is set, send consent: true.
  • Phone needs at least 9 digits; email needs an @ and a domain.
  • Returns: 200 {"ok": true, "conversation_id"} · 200 {"ok": true, "skipped": true} when the form is off · 422 {"ok": false, "error": "invalid", "field", "reason"}

3.6 Talking to a person

There is no separate endpoint: send a normal message such as “I'd like to talk to someone”, or make a Help button that sends it. The bot hands over to your team, who reply in the same conversation (sender_type "human").

/webhooks/web/handoff and /handoff/claim are not a hand-over to a person — they move a conversation from the widget to the full-page chat. An app doesn't need them.

4. Notify on reply (webhook → push)

When the app is closed it isn't polling. Set a webhook and AgenticOS calls your server on every reply from the bot or your team; your server sends the FCM/APNs push. AgenticOS never holds device tokens.

4.1 Set up

  1. OmniChat → Channels → your web channel → “Mobile app notifications on reply”: enter your server's URL (https, public host) and save. A signing key is created.
  2. Copy the signing key to your server.
  3. Press “Send a test event” — you should see your server answered 200.

4.2 What your server receives

headerValue
X-AgenticOS-Eventmessage.created (or test from the test button)
X-AgenticOS-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
User-AgentAgenticOS-Webhook/1.0
body
{
  "event": "message.created",
  "channel_id": "75538105-e14a-4f76-bb2d-dd1cc048b0bc",
  "conversation_id": "183b01f7-fd8e-4951-a07f-d323fbf6cfac",
  "user_id": "member-42",
  "session_id": null,
  "message": {
    "id": "77072727-82a3-4497-ad6d-7582175cf8a4",
    "sender": "bot",
    "sender_name": "Assistant",
    "preview": "The payback period depends on several things…",
    "has_attachment": false,
    "created_at": "2026-09-30T08:15:02.418221+00:00"
  },
  "sent_at": "2026-09-30T08:15:04.102118+00:00",
  "visitor_active": false
}
keyMeaning
user_id / session_idWho it's for — exactly one is set. Use it to find the user's device tokens.
message.sender"bot" or "agent" (your team)
message.previewUp to 200 characters — use it as the notification body
message.idUse it to collapse duplicates
visitor_activetrue when the user polled in the last ~100 s — they probably have the app open, so you may skip the push
  • It is a signal, not the transcript — the app reads the full messages through poll.
  • Several replies within ~1.5 s arrive as one event.
  • Answer 2xx within 5 s. On a timeout or 5xx we retry twice (after 2 s and 8 s); a 4xx is not retried.
  • Order isn't guaranteed, duplicates can happen, and redirects are not followed.

4.3 Verify the signature (required)

  1. Split t and v1 out of the header.
  2. Compute HMAC-SHA256(signing key, "<t>." + raw body) as hex, on the raw body before parsing JSON.
  3. Compare with v1 in constant time; answer 401 on mismatch.
  4. Reject a t older than 5 minutes (replay).
Node.js (Express)
app.post("/omnichat/reply", express.raw({ type: "application/json" }), (req, res) => {
  const sig = Object.fromEntries(String(req.get("X-AgenticOS-Signature") || "")
    .split(",").map((p) => p.split("=", 2)));
  const expected = crypto.createHmac("sha256", process.env.OMNICHAT_PUSH_KEY)
    .update(`${sig.t}.`).update(req.body).digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(sig.t)) < 300;
  if (!fresh || !sig.v1 || sig.v1.length !== expected.length
      || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig.v1)))
    return res.sendStatus(401);
  const evt = JSON.parse(req.body);
  res.sendStatus(200);                       // answer first, then push
  if (evt.event === "message.created" && !evt.visitor_active) {
    sendPush(evt.user_id ?? evt.session_id, {
      title: evt.message.sender_name || "New message",
      body: evt.message.preview || "📎 Attachment",
      collapseKey: evt.message.id,
      data: { conversation_id: evt.conversation_id },
    });
  }
});
Python (FastAPI)
@app.post("/omnichat/reply")
async def omnichat_reply(request: Request):
    raw = await request.body()
    parts = dict(p.split("=", 1) for p in request.headers.get("X-AgenticOS-Signature", "").split(","))
    expected = hmac.new(PUSH_KEY.encode(), f"{parts.get('t')}.".encode() + raw, hashlib.sha256).hexdigest()
    if abs(time.time() - int(parts.get("t", 0))) > 300 or not hmac.compare_digest(expected, parts.get("v1", "")):
        raise HTTPException(401)
    evt = json.loads(raw)
    # … push to evt["user_id"] or evt["session_id"]
    return {"ok": True}

Register device tokens with your own server, keyed by user_id (or session_id for anonymous users).

5. Rate limit

BucketendpointLimit
MessagesPOST /webhooks/web /prechatSet per organisation — default 20/min per token per IP
Uploads/uploadSame limit, counted separately
poll/poll180/min per token per IP
config/configNone

On 429, wait 2–5 s and try again.

6. Suggested app flow

  1. Open the chat screen: GET /config.
  2. If signed in: fetch user_hash from your backend.
  3. GET /poll without after to show the history; keep the cursor.
  4. If prechat is set and prechat_done is false: show the form, then POST /prechat.
  5. The user types: show it at once, POST /webhooks/web, then poll every 1 s for 20 s.
  6. Leaving the screen or backgrounding the app: stop polling.
  7. A push arrives: open the chat and GET /poll?after=<saved cursor>.

7. Troubleshooting

SymptomUsual cause
Messages send but no reply showsPoll isn't sending the same user_id/user_hash, or the cursor isn't URL-encoded
The user shows as anonymoususer_hash is computed wrong, or no identity key has been created
409 prechat_requiredSend the pre-chat form, or tie the user to a member to skip it
No webhook arrivesThe URL isn't https, resolves to a private IP, or your server doesn't answer 2xx — try “Send a test event”
Signature mismatchThe body was re-serialised instead of using the raw bytes, or the wrong key was used (it must be the signing key)