BYOAI
Deine eigene KI als Assistent
Du hast schon eine KI – mit deinem Modell, deinem Prompt, deinen Daten? Dann leg in meiti einen Assistenten vom Typ „Eigene KI“ an. meiti schickt jede Chat-Nachricht per signiertem Webhook und jeden Anruf per WebSocket an deine KI und gibt ihre Antwort an den Kunden weiter. Nummern und Kanäle, Spracherkennung und Stimme, das Postfach mit dem gespeicherten Verlauf und die Übergabe an dein Team bleiben bei meiti.
Soll meitis Assistent antworten und nur wissen, wer da schreibt oder anruft? Dafür reicht der CRM-Webhook. BYOAI brauchst du, wenn deine KI selbst antworten soll.
Ohne Technik erklärt, was das für deinen Betrieb heißt: Anwendungsfall BYOAI.
POST an deine https-Adresse (chatUrl), Ereignis message.received – WhatsApp, Website-Chat, Telegramwss-Adresse (voiceUrl)X-Meiti-Signature (HMAC-SHA256) mit dem Schlüssel des AssistentenX-Meiti-Agent-Protocol: 2026-10-11Einrichten
- In der meiti App: Einstellungen → KI-Assistenten → NEU → „Eigene KI“, Namen vergeben. Die Seite des neuen Assistenten öffnet sich. Wie die API steht das in jedem Postfach zur Verfügung.
- Chat-Adresse (https) und Wartezeit auf eine direkte Antwort (Standard 10 Sekunden, 2–30), Telefon-Adresse (
wss) und Stimme eintragen, speichern. Beide Adressen müssen öffentlich erreichbar sein. - „Schlüssel erzeugen“: Er wird nur einmal angezeigt – leg ihn als
MEITI_AGENT_SECRETab. Ohne Schlüssel schickt meiti nichts an deine KI. „Schlüssel ersetzen“ macht den bisherigen sofort ungültig. - „Verbindung testen“: ein signierter
pingan die Chat-Adresse (jede2xx-Antwort besteht), danach einmal den Telefon-WebSocket öffnen und schließen. - Unter „Wer antwortet wo?“ den Kanälen zuweisen – wie jeden anderen Assistenten.
Alles außer dem Anlegen geht auch per API, mit einem Schlüssel desselben Postfachs:
# Your own-AI assistants (create them in the app: Settings → AI assistants → NEW → "Your own AI")
curl https://api.meiti.ai/v1/agents -H "X-Api-Key: $MEITI_API_KEY"
# Point one at your endpoints. Omitted fields stay as they are; "" disconnects a channel.
curl -X PATCH https://api.meiti.ai/v1/agents/812 \
-H "X-Api-Key: $MEITI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"chatUrl": "https://ai.example.com/meiti/chat",
"voiceUrl": "wss://ai.example.com/meiti/voice",
"timeoutSeconds": 10
}'
# Signing secret: the response { "secret": "whsec_…" } is the only time you can read it.
curl -X POST https://api.meiti.ai/v1/agents/812/secret -H "X-Api-Key: $MEITI_API_KEY"
# Signed "ping" to chatUrl, then one WebSocket connect to voiceUrl.
curl -X POST https://api.meiti.ai/v1/agents/812/test -H "X-Api-Key: $MEITI_API_KEY"
# 200: { success, statusCode, durationMs, error }
const meiti = (path, method = "GET", body) =>
fetch(`https://api.meiti.ai${path}`, {
method,
headers: { "X-Api-Key": process.env.MEITI_API_KEY, "Content-Type": "application/json" },
body: body && JSON.stringify(body),
}).then(r => r.json());
const [agent] = await meiti("/v1/agents"); // created in the app as "Your own AI"
await meiti(`/v1/agents/${agent.agentId}`, "PATCH", {
chatUrl: "https://ai.example.com/meiti/chat",
voiceUrl: "wss://ai.example.com/meiti/voice", // "" disconnects a channel
timeoutSeconds: 10,
});
const { secret } = await meiti(`/v1/agents/${agent.agentId}/secret`, "POST"); // shown exactly once
const test = await meiti(`/v1/agents/${agent.agentId}/test`, "POST"); // { success, statusCode, durationMs, error }
import os, requests
api = requests.Session()
api.headers["X-Api-Key"] = os.environ["MEITI_API_KEY"]
base = "https://api.meiti.ai/v1"
agent = api.get(f"{base}/agents").json()[0] # created in the app as "Your own AI"
api.patch(f"{base}/agents/{agent['agentId']}", json={
"chatUrl": "https://ai.example.com/meiti/chat",
"voiceUrl": "wss://ai.example.com/meiti/voice", # "" disconnects a channel
"timeoutSeconds": 10,
})
secret = api.post(f"{base}/agents/{agent['agentId']}/secret").json()["secret"] # shown exactly once
test = api.post(f"{base}/agents/{agent['agentId']}/test").json() # success, statusCode, durationMs, error
// call() from the quickstart. GET /v1/agents answers with a JSON array; read the id from the app or decode it yourself.
id := "812" // agentId of your "Your own AI" assistant
_, status, err := call("PATCH", "/v1/agents/"+id, map[string]any{
"chatUrl": "https://ai.example.com/meiti/chat",
"voiceUrl": "wss://ai.example.com/meiti/voice", // "" disconnects a channel
"timeoutSeconds": 10,
})
if err != nil || status != 200 {
log.Fatal(status, err)
}
created, _, _ := call("POST", "/v1/agents/"+id+"/secret", nil)
saveSecret(created["secret"].(string)) // shown exactly once
test, _, _ := call("POST", "/v1/agents/"+id+"/test", nil)
fmt.Println(test["success"], test["durationMs"], test["error"])
// http from the quickstart (BaseAddress + X-Api-Key)
var agents = await http.GetFromJsonAsync<JsonElement>("v1/agents"); // created in the app as "Your own AI"
var id = agents[0].GetProperty("agentId").GetInt32();
await http.PatchAsJsonAsync($"v1/agents/{id}", new
{
chatUrl = "https://ai.example.com/meiti/chat",
voiceUrl = "wss://ai.example.com/meiti/voice", // "" disconnects a channel
timeoutSeconds = 10,
});
var created = await (await http.PostAsync($"v1/agents/{id}/secret", null)).Content.ReadFromJsonAsync<JsonElement>();
var secret = created.GetProperty("secret").GetString(); // shown exactly once
var test = await (await http.PostAsync($"v1/agents/{id}/test", null)).Content.ReadFromJsonAsync<JsonElement>();
<?php
function meiti(string $method, string $path, ?array $body = null): array {
$ch = curl_init("https://api.meiti.ai" . $path);
$options = [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["X-Api-Key: " . getenv("MEITI_API_KEY"), "Content-Type: application/json"],
];
if ($method !== "GET") {
$options[CURLOPT_POSTFIELDS] = $body === null ? "" : json_encode($body);
}
curl_setopt_array($ch, $options);
return json_decode(curl_exec($ch), true);
}
$id = meiti("GET", "/v1/agents")[0]["agentId"]; // created in the app as "Your own AI"
meiti("PATCH", "/v1/agents/$id", [
"chatUrl" => "https://ai.example.com/meiti/chat",
"voiceUrl" => "wss://ai.example.com/meiti/voice", // "" disconnects a channel
"timeoutSeconds" => 10,
]);
$secret = meiti("POST", "/v1/agents/$id/secret")["secret"]; // shown exactly once
$test = meiti("POST", "/v1/agents/$id/test"); // success, statusCode, durationMs, error
| Endpunkt | Wofür |
|---|---|
GET /v1/agents, GET /v1/agents/{id} | Die Assistenten mit eigener KI: agentId, name, chatUrl, voiceUrl, timeoutSeconds, hasSecret, voiceId und der Verbindungsstatus lastSuccessAt, lastFailureAt, lastError, consecutiveFailures. |
PATCH /v1/agents/{id} | chatUrl (https), voiceUrl (wss), timeoutSeconds (auf 2–30 begrenzt), voiceId (ElevenLabs-Stimme; leer = meitis Standardstimme). Weggelassen = unverändert, "" = Kanal trennen. 400 invalid_chat_url bzw. invalid_voice_url, wenn eine Adresse nicht erlaubt ist. |
POST /v1/agents/{id}/secret | Neuer Signatur-Schlüssel { secret } – nur in dieser Antwort lesbar. Der bisherige gilt sofort nicht mehr. |
POST /v1/agents/{id}/test | Signierter ping an chatUrl, dann ein Verbindungsaufbau zu voiceUrl: { success, statusCode, durationMs, error }. |
POST /v1/projects/{id}/agent-replies | Die spätere Antwort auf ein message.received – siehe unten. |
Chat: message.received
Schreibt ein Kunde auf WhatsApp, im Website-Chat oder auf Telegram und beantwortet dein Assistent mit eigener KI das Gespräch, schickt meiti das Ereignis als POST mit JSON-Body an deine chatUrl:
| Header | Inhalt |
|---|---|
X-Meiti-Signature | t=<Unix-Sekunden>,v1=<hex HMAC-SHA256(secret, t + "." + body)> – über den unveränderten Body. |
X-Meiti-Event | message.received oder ping (Verbindungstest, ohne data). |
X-Meiti-Delivery-Id | Eindeutig je Zustellung, gleich id im Body (evt_…) – zum Protokollieren und Entdoppeln. |
X-Meiti-Agent-Protocol | Protokollstand, derzeit 2026-10-11; steht auch als protocol im Body. |
So sieht message.received aus
{
"id": "evt_3f9a0c1e8b7d4b2a9e6f5d4c3b2a1908",
"type": "message.received",
"createdAt": "2026-10-14T09:14:03.5123456Z",
"protocol": "2026-10-11",
"workspaceId": 245,
"agentId": 812,
"data": {
"projectId": 67890,
"channel": "whatsapp",
"contact": { "id": 18342, "name": "Ingrid Weber", "phone": "+491701234567" },
"message": { "id": 991250, "text": "Kann ich die Lieferadresse noch ändern?", "receivedAt": "2026-10-14T09:14:02.947" },
"history": [
{ "role": "customer", "text": "Hallo, ist meine Bestellung 4711 schon unterwegs?", "at": "2026-10-14T09:12:40.118" },
{ "role": "agent", "text": "Ja, sie ist gestern rausgegangen und kommt morgen bei Ihnen an.", "at": "2026-10-14T09:12:43.502" }
],
"replyUrl": "https://api.meiti.ai/v1/projects/67890/agent-replies",
"skills": [
{ "id": 412, "key": "buero", "name": "Ins Büro durchstellen", "kind": "forward", "announcement": "Einen Moment, ich verbinde Sie mit dem Büro." },
{ "id": 418, "key": "notdienst", "name": "Notdienst-Alarm", "description": "Wasserschaden, Heizungsausfall", "kind": "alert", "announcement": null }
],
"autoReply": { "state": "on" }
}
}
channel:whatsapp,webchat,telegramoderchat. Unbekannte Werte und neue Felder ignorieren – es kommen welche dazu.contact:id, dazuname,phoneundemail, soweit bekannt – fehlende Felder werden weggelassen.history: bis zu 30 frühere Beiträge, älteste zuerst, ohne die neue Nachricht.role:customer,agent(KI-Antworten),team(jemand aus deinem Team) odersystem(Hinweise von meiti).autoReply: die Auto-Antwort des Gesprächs, wie dein Team sie in der App gesetzt hat –state(on,paused,off) und bei einer PausepausedUntil. Siehe Wenn dein Team übernimmt.- Zeiten ohne Zeitzone (
2026-10-14T09:14:02.947) sind UTC. - Ein Chat mit eigener KI beginnt ohne meitis Begrüßung und Datenschutz-Hinweis: Die erste Antwort kommt von deiner KI.
Der kleinste vollständige Endpunkt: Signatur prüfen, Ereignis lesen, direkt antworten.
// Node + Express. myAI stands for your own model, prompt and data.
import crypto from "node:crypto";
import express from "express";
const app = express();
const secret = process.env.MEITI_AGENT_SECRET; // from "Create secret" or POST /v1/agents/{id}/secret
// X-Meiti-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, t + "." + signed)>
function verify(header, signed) {
const sig = Object.fromEntries((header ?? "").split(",").map(p => p.split("=")));
const expected = crypto.createHmac("sha256", secret).update(`${sig.t}.${signed}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(sig.t)) < 300;
return fresh && sig.v1?.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig.v1), Buffer.from(expected));
}
// Raw body: the signature covers the exact bytes meiti sent.
app.post("/meiti/chat", express.raw({ type: "application/json" }), async (req, res) => {
if (!verify(req.get("X-Meiti-Signature"), req.body)) return res.sendStatus(401);
const event = JSON.parse(req.body);
if (event.type !== "message.received") return res.sendStatus(200); // "ping" from the connection test
if (event.data.autoReply.state !== "on") return res.sendStatus(204); // your team has it — stay quiet (or answer anyway)
const { message, history, contact } = event.data;
const answer = await myAI({ text: message.text, history, contact }); // within timeoutSeconds
res.json({
text: answer.text,
action: answer.needsPerson ? "handover" : "none", // handover: your team takes over in the inbox
note: answer.needsPerson ? answer.reason : undefined, // only your team sees it
});
});
app.listen(3000);
# Python + Flask. my_ai stands for your own model, prompt and data.
import hashlib, hmac, json, os, time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["MEITI_AGENT_SECRET"].encode() # from "Create secret" or POST /v1/agents/{id}/secret
# X-Meiti-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, t + "." + signed)>
def verify(header: str, signed: bytes) -> bool:
sig = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t, v1 = sig.get("t", ""), sig.get("v1", "")
expected = hmac.new(SECRET, t.encode() + b"." + signed, hashlib.sha256).hexdigest()
return t.isdigit() and abs(time.time() - int(t)) <= 300 and hmac.compare_digest(v1, expected)
@app.post("/meiti/chat")
def meiti_chat():
body = request.get_data() # the exact bytes meiti signed
if not verify(request.headers.get("X-Meiti-Signature", ""), body):
abort(401)
event = json.loads(body)
if event["type"] != "message.received":
return "", 200 # "ping" from the connection test
data = event["data"]
if data["autoReply"]["state"] != "on":
return "", 204 # your team has it — stay quiet (or answer anyway)
answer = my_ai(text=data["message"]["text"], history=data["history"], contact=data.get("contact"))
reply = {"text": answer.text, "action": "handover" if answer.needs_person else "none"}
if answer.needs_person:
reply["note"] = answer.reason # only your team sees it
return reply
// net/http. myAI stands for your own model, prompt and data.
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
// verify checks X-Meiti-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, t + "." + signed)>.
func verify(header string, signed []byte) bool {
var t, v1 string
for _, part := range strings.Split(header, ",") {
k, v, _ := strings.Cut(part, "=")
switch k {
case "t":
t = v
case "v1":
v1 = v
}
}
unix, err := strconv.ParseInt(t, 10, 64)
mac := hmac.New(sha256.New, []byte(os.Getenv("MEITI_AGENT_SECRET")))
mac.Write([]byte(t + "."))
mac.Write(signed)
expected := hex.EncodeToString(mac.Sum(nil))
age := time.Since(time.Unix(unix, 0))
return err == nil && age <= 5*time.Minute && age >= -5*time.Minute && hmac.Equal([]byte(expected), []byte(v1))
}
type turn struct {
Role string `json:"role"`
Text string `json:"text"`
}
func meitiChat(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body) // the exact bytes meiti signed
if !verify(r.Header.Get("X-Meiti-Signature"), body) {
w.WriteHeader(http.StatusUnauthorized)
return
}
var event struct {
Type string `json:"type"`
Data struct {
Message struct {
Text string `json:"text"`
} `json:"message"`
History []turn `json:"history"`
ReplyURL string `json:"replyUrl"`
AutoReply struct {
State string `json:"state"`
} `json:"autoReply"`
} `json:"data"`
}
if err := json.Unmarshal(body, &event); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if event.Type != "message.received" {
w.WriteHeader(http.StatusOK) // "ping" from the connection test
return
}
if event.Data.AutoReply.State != "on" {
w.WriteHeader(http.StatusNoContent) // your team has it — stay quiet (or answer anyway)
return
}
answer := myAI(event.Data.Message.Text, event.Data.History)
reply := map[string]any{"text": answer.Text, "action": "none"}
if answer.NeedsPerson {
reply["action"] = "handover" // your team takes over in the inbox
reply["note"] = answer.Reason // only your team sees it
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(reply)
}
func main() {
http.HandleFunc("POST /meiti/chat", meitiChat)
http.ListenAndServe(":3000", nil)
}
// ASP.NET Core minimal API (works from .NET 8 on). MyAI stands for your own model, prompt and data.
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
var app = WebApplication.CreateBuilder(args).Build();
var secret = Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("MEITI_AGENT_SECRET")!);
// X-Meiti-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, t + "." + signed)>
bool Verify(string header, byte[] signed)
{
var sig = header.Split(',').Select(p => p.Split('=', 2)).Where(p => p.Length == 2).ToDictionary(p => p[0], p => p[1]);
if (!sig.TryGetValue("t", out var t) || !long.TryParse(t, out var unix) || !sig.TryGetValue("v1", out var v1)
|| Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - unix) > 300)
return false;
var expected = Convert.ToHexString(HMACSHA256.HashData(secret, Encoding.UTF8.GetBytes(t + ".").Concat(signed).ToArray())).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(v1));
}
app.MapPost("/meiti/chat", async (HttpRequest request) =>
{
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer);
var body = buffer.ToArray(); // the exact bytes meiti signed
if (!Verify(request.Headers["X-Meiti-Signature"].ToString(), body))
return Results.Unauthorized();
using var evt = JsonDocument.Parse(body);
if (evt.RootElement.GetProperty("type").GetString() != "message.received")
return Results.Ok(); // "ping" from the connection test
var data = evt.RootElement.GetProperty("data");
if (data.GetProperty("autoReply").GetProperty("state").GetString() != "on")
return Results.NoContent(); // your team has it — stay quiet (or answer anyway)
var answer = await MyAI.AnswerAsync(data.GetProperty("message").GetProperty("text").GetString(), data.GetProperty("history"));
return Results.Ok(new
{
text = answer.Text,
action = answer.NeedsPerson ? "handover" : "none", // handover: your team takes over in the inbox
note = answer.NeedsPerson ? answer.Reason : null, // only your team sees it
});
});
app.Run();
<?php
// meiti-chat.php. myAi() stands for your own model, prompt and data.
$body = file_get_contents("php://input"); // the exact bytes meiti signed
parse_str(str_replace(",", "&", $_SERVER["HTTP_X_MEITI_SIGNATURE"] ?? ""), $sig);
$t = $sig["t"] ?? "";
$expected = hash_hmac("sha256", $t . "." . $body, getenv("MEITI_AGENT_SECRET"));
if (!ctype_digit($t) || abs(time() - (int)$t) > 300 || !hash_equals($expected, $sig["v1"] ?? "")) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
if ($event["type"] !== "message.received") { http_response_code(200); exit; } // "ping" from the connection test
$data = $event["data"];
if ($data["autoReply"]["state"] !== "on") { http_response_code(204); exit; } // your team has it — stay quiet (or answer anyway)
$answer = myAi($data["message"]["text"], $data["history"], $data["contact"] ?? null);
header("Content-Type: application/json");
echo json_encode([
"text" => $answer["text"],
"action" => $answer["needsPerson"] ? "handover" : "none", // handover: your team takes over in the inbox
"note" => $answer["needsPerson"] ? $answer["reason"] : null, // only your team sees it
]);
Antworten: sofort oder später
| Deine Antwort | Was meiti tut |
|---|---|
200 + { text, action, note } | Schickt text sofort auf dem Kanal des Kunden und führt die Aktion aus. |
202, 204 oder 200 ohne Body | Wartet. Deine Antwort kommt später per POST /v1/projects/{id}/agent-replies – die volle Adresse steht in data.replyUrl. |
| Alles andere | Kein 2xx, Zeitüberschreitung, Antwort über 64 KB, kein gültiges JSON, unbekannte action oder text über 4000 Zeichen: Die Nachricht wartet mit einem Hinweis auf dein Team (siehe unten). |
| Feld | Bedeutung |
|---|---|
text | Die Antwort an den Kunden, höchstens 4000 Zeichen. Leer = nichts senden. |
action | none (Standard): Deine KI bleibt am Gespräch. handover: Das Gespräch geht an dein Team – es wartet im Postfach, dein Team wird benachrichtigt, und die folgenden message.received tragen autoReply.state: off, bis jemand es mit „An meiti übergeben“ zurückgibt. end: Das Anliegen ist erledigt; im Chat ändert das außer dem Text nichts. |
note | Ein Hinweis nur für dein Team: Er steht im Verlauf, der Kunde sieht ihn nicht. Höchstens 500 Zeichen, Längeres wird gekürzt. Gedacht für handover. |
Braucht deine KI länger, antworte sofort mit 202 und schick die Antwort danach – mit demselben Body und einem API-Schlüssel desselben Postfachs:
# Your endpoint answered 202 (or 204). Later, with an API key of the same inbox:
curl https://api.meiti.ai/v1/projects/67890/agent-replies \
-H "X-Api-Key: $MEITI_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "text": "Ihre neue Lieferadresse ist eingetragen.", "action": "none" }'
# 202: { "projectId": 67890, "messageId": 991262, "status": "sent", "autoReply": { "state": "on" } }
# "autoReply.state" other than "on" = your team took over in the meantime; the text went out anyway.
# 409 project_not_with_agent: no own-AI assistant answers this project — drop the answer.
app.post("/meiti/chat", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.get("X-Meiti-Signature"), req.body)) return res.sendStatus(401);
const event = JSON.parse(req.body);
res.sendStatus(202); // answer right away …
if (event.type !== "message.received" || event.data.autoReply.state !== "on") return; // your team has it
myAI(event.data).then(async answer => { // … and reply when your AI is done
const r = await fetch(event.data.replyUrl, {
method: "POST",
headers: { "X-Api-Key": process.env.MEITI_API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ text: answer.text, action: "none" }),
});
const accepted = r.status === 202 ? await r.json() : null;
if (accepted && accepted.autoReply.state !== "on") console.log("your team took over meanwhile — the answer went out anyway");
});
});
import threading, requests
@app.post("/meiti/chat")
def meiti_chat():
body = request.get_data()
if not verify(request.headers.get("X-Meiti-Signature", ""), body):
abort(401)
event = json.loads(body)
if event["type"] == "message.received" and event["data"]["autoReply"]["state"] == "on":
def later():
answer = my_ai(event["data"]) # takes as long as it takes
res = requests.post(
event["data"]["replyUrl"],
headers={"X-Api-Key": os.environ["MEITI_API_KEY"]},
json={"text": answer.text, "action": "none"},
timeout=10,
)
if res.status_code == 202 and res.json()["autoReply"]["state"] != "on":
print("your team took over meanwhile — the answer went out anyway")
threading.Thread(target=later).start()
return "", 202 # answer right away
// In meitiChat above, instead of the direct reply (add "bytes" to its imports):
w.WriteHeader(http.StatusAccepted) // answer right away …
go func(replyURL, text string) { // … and reply when your AI is done
answer := myAI(text, nil)
b, _ := json.Marshal(map[string]any{"text": answer.Text, "action": "none"})
req, _ := http.NewRequest("POST", replyURL, bytes.NewReader(b))
req.Header.Set("X-Api-Key", os.Getenv("MEITI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err == nil {
res.Body.Close() // 202 with "autoReply": { "state": "off" } = your team took over meanwhile, the text went out anyway
}
}(event.Data.ReplyURL, event.Data.Message.Text)
// Answer 202 and queue the event like the CRM callback worker above (the request is gone after the 202).
// In the worker: http from the quickstart — its X-Api-Key header authorises the reply; replyUrl is absolute.
var replyUrl = evt.GetProperty("data").GetProperty("replyUrl").GetString();
var res = await http.PostAsJsonAsync(replyUrl, new { text = answer.Text, action = "none" }, ct);
if (res.StatusCode == System.Net.HttpStatusCode.Accepted
&& (await res.Content.ReadFromJsonAsync<JsonElement>(ct)).GetProperty("autoReply").GetProperty("state").GetString() != "on")
Console.WriteLine("your team took over meanwhile — the answer went out anyway");
<?php
// … signature check as in the handler above …
$event = json_decode($body, true);
http_response_code(202); // answer right away …
if (function_exists("fastcgi_finish_request")) {
fastcgi_finish_request(); // … PHP-FPM closes the request, the script keeps running
}
if ($event["type"] !== "message.received" || $event["data"]["autoReply"]["state"] !== "on") exit; // your team has it
$answer = myAi($event["data"]);
$ch = curl_init($event["data"]["replyUrl"]);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["X-Api-Key: " . getenv("MEITI_API_KEY"), "Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode(["text" => $answer["text"], "action" => "none"]),
]);
curl_exec($ch); // 202 with "autoReply": { "state": "off" } = your team took over meanwhile, the text went out anyway
202 { projectId, messageId, status, autoReply }:statusistsent(Text ging raus),handed_over(an dein Team übergeben) odernoop(nichts zu tun);messageIdistnull, wenn nur eine Aktion kam.autoReplyist der Stand beim Eintreffen – alles außeronheißt, dein Team hatte inzwischen übernommen (siehe Wenn dein Team übernimmt).409 project_not_with_agent: Das Gespräch wird nicht von einem Assistenten mit eigener KI beantwortet (z. B. einem anderen Assistenten zugewiesen) – verwirf die Antwort.422 not_deliverable: Der Text ließ sich nicht zustellen, das Gespräch ging an dein Team.404 project_not_found: keine solche Unterhaltung in diesem Postfach.400 invalid_reply: unbekannteactionoder zu langertext.- Die Antwort geht auf dem Kanal raus, auf dem der Kunde zuletzt geschrieben hat. Sie zählt zum Limit von 60 Anfragen pro Minute und Schlüssel.
Wenn dein Team übernimmt
Antwortet jemand aus deinem Team selbst, übernimmt das Gespräch oder schaltet die Auto-Antwort dafür aus oder stumm, würde meitis eigener Assistent schweigen. Deine KI bekommt message.received trotzdem weiter – mit der Auto-Antwort in data.autoReply – und entscheidet selbst, ob sie antwortet:
autoReply.state | Bedeutung |
|---|---|
on | Deine KI soll antworten. |
paused | Für dieses Gespräch stummgeschaltet, bis pausedUntil; danach wieder on. |
off | Dein Team antwortet: Jemand hat geantwortet oder übernommen, eine Übergabe wartet, oder die Auto-Antwort ist dafür aus (oder nur Empfangsbestätigung). Das bleibt so, bis jemand „An meiti übergeben“ wählt. |
- Still bleiben: ist
statenichton, mit204antworten. Die Beispiele oben tun genau das. - Antwortet deine KI trotzdem, stellt meiti den Text zu – sofort oder per
agent-replies. Beim ersten Text nach dem Eingreifen deines Teams schreibt meiti einen Hinweis ins Gespräch, dass deine KI trotz ausgeschalteter Auto-Antwort geantwortet hat; schreibt jemand aus dem Team erneut, gilt das wieder für den nächsten Text. - meitis eigene Schritte folgen der Auto-Antwort: Solange sie nicht
onist, istdata.skillsleer,executeSkillsantwortet je Skill mitnot-allowed,POST /v1/projects/{id}/skills/{key}mit409 project_not_with_agent, undhandoverhat nichts mehr zu übergeben. - Fällt deine KI aus, während dein Team das Gespräch hat, schreibt meiti keinen Hinweis und benachrichtigt niemanden – dein Team ist ja schon dran. Der Fehler steht auf der Seite des Assistenten.
Wenn deine KI nicht antwortet
- meiti wartet
timeoutSeconds(Standard 10, 2–30) auf die direkte Antwort und versucht es genau einmal – ohne Wiederholung. - Ein Fehler endet nie im Leeren: Die Nachricht bleibt im Postfach, meiti schreibt den Grund als Hinweis dazu und übergibt an dein Team. Ohne Chat-Adresse oder ohne Schlüssel geht jede Nachricht direkt an dein Team.
- Den Status zeigen die Seite des Assistenten („Zuletzt erreicht …“, „Nicht erreichbar seit …“) und
GET /v1/agents/{id}:lastSuccessAt,lastFailureAt,lastError,consecutiveFailures.
Telefon: ein WebSocket pro Anruf
Für jeden Anruf, den dein Assistent annimmt, öffnet meiti eine WebSocket-Verbindung zu deiner voiceUrl. meiti wandelt die Worte des Anrufers in Text um, schickt jeden fertigen Satz an deine KI und spricht deine Textantworten mit der gewählten Stimme. Die Begrüßung spricht meiti, bevor der erste Satz zu deiner KI geht. Beim Verbindungsaufbau kommen diese Header:
| Header | Inhalt |
|---|---|
X-Meiti-Call-Id | meitis ID des Anrufs, auch als callId in session.started. |
X-Meiti-Signature | Wie im Chat, nur über die Call-ID statt über einen Body: hex HMAC-SHA256(secret, t + "." + callId). Lehne die Verbindung ohne gültige Signatur ab. |
X-Meiti-Agent-Protocol | 2026-10-11 |
| Frame | Bedeutung |
|---|---|
| meiti → deine KI | |
session.started | protocol, callId, callSid, projectId, workspaceId, agentId, from, to, language (de-DE oder en-US), contact { id, name } und resumed: "handover_failed", wenn eine Übernahme durch dein Team nicht geklappt hat und deine KI weitermacht. |
prompt { text } | Der Anrufer hat einen Satz beendet. |
dtmf { digit } | Der Anrufer hat eine Taste gedrückt. |
interrupt { spokenUntilInterrupt } | Der Anrufer hat dazwischengeredet; spokenUntilInterrupt ist, was bis dahin gesprochen war. Hör auf, die alte Antwort zu streamen. |
session.ended | Der Anruf ist für deine KI vorbei; meiti schließt danach die Verbindung. |
| deine KI → meiti | |
text { text, last } | Zu sprechender Text, gern in Stücken gestreamt; last: true schließt die Antwort ab. token statt text wird auch angenommen – Agenten, die für Twilio ConversationRelay gebaut sind, passen also. |
action { action, text, note } | end: text wird zu Ende gesprochen, dann legt meiti auf. handover: text wird gesprochen, dein Team wird benachrichtigt, und der Anrufer hinterlässt eine Nachricht; note erscheint als Hinweis im Verlauf. |
So sieht session.started aus
{
"type": "session.started",
"protocol": "2026-10-11",
"callId": "482113",
"callSid": "CA5f0c2e…",
"projectId": 67890,
"workspaceId": 245,
"agentId": 812,
"from": "+491701234567",
"to": "+4930123456789",
"language": "de-DE",
"contact": { "id": 18342, "name": "Ingrid Weber" },
"skills": [
{ "id": 412, "key": "buero", "name": "Ins Büro durchstellen", "kind": "forward", "announcement": "Einen Moment, ich verbinde Sie mit dem Büro." }
]
}
// Node + ws (npm i ws). Serve it behind TLS: meiti only connects to wss:// addresses.
import http from "node:http";
import { WebSocketServer } from "ws";
const server = http.createServer();
const wss = new WebSocketServer({ noServer: true });
// The upgrade has no body: the signature covers the X-Meiti-Call-Id header (verify() from the chat handler).
server.on("upgrade", (req, socket, head) => {
if (!verify(req.headers["x-meiti-signature"], req.headers["x-meiti-call-id"] ?? "")) {
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
return socket.destroy();
}
wss.handleUpgrade(req, socket, head, ws => wss.emit("connection", ws, req));
});
wss.on("connection", ws => {
let call;
const send = frame => ws.send(JSON.stringify(frame));
ws.on("message", async raw => {
const frame = JSON.parse(raw);
switch (frame.type) {
case "session.started": // callId, projectId, from, to, language, contact, resumed?
call = frame;
break;
case "prompt": { // the caller finished a sentence
const answer = await myAI(call, frame.text);
if (answer.done) send({ type: "action", action: "end", text: answer.text }); // spoken, then hang up
else if (answer.needsPerson) send({ type: "action", action: "handover", text: answer.text, note: answer.reason });
else send({ type: "text", text: answer.text, last: true }); // or stream pieces; the last one has last: true
break;
}
case "interrupt": // the caller talked over you: stop streaming the old answer
break;
case "session.ended":
ws.close();
break;
}
});
});
server.listen(8080);
# Python + websockets 13+ (pip install websockets). Serve it behind TLS: meiti only connects to wss://.
import asyncio, json
from http import HTTPStatus
from websockets.asyncio.server import serve
def check(connection, request):
# The upgrade has no body: the signature covers the X-Meiti-Call-Id header (verify() from the chat handler).
call_id = request.headers.get("X-Meiti-Call-Id", "")
if not verify(request.headers.get("X-Meiti-Signature", ""), call_id.encode()):
return connection.respond(HTTPStatus.UNAUTHORIZED, "bad signature\n")
async def handle(ws):
call = None
async for raw in ws:
frame = json.loads(raw)
if frame["type"] == "session.started": # callId, projectId, from, to, language, contact, resumed?
call = frame
elif frame["type"] == "prompt": # the caller finished a sentence
answer = await my_ai(call, frame["text"])
if answer.done: # spoken, then hang up
await ws.send(json.dumps({"type": "action", "action": "end", "text": answer.text}))
elif answer.needs_person:
await ws.send(json.dumps({"type": "action", "action": "handover", "text": answer.text, "note": answer.reason}))
else: # or stream pieces; the last one has "last": True
await ws.send(json.dumps({"type": "text", "text": answer.text, "last": True}))
elif frame["type"] == "session.ended":
break
async def main():
async with serve(handle, "0.0.0.0", 8080, process_request=check) as server:
await server.serve_forever()
asyncio.run(main())
// net/http + github.com/gorilla/websocket. Serve it behind TLS: meiti only connects to wss:// addresses.
var upgrader = websocket.Upgrader{}
type frame struct {
Type string `json:"type"`
Text string `json:"text"`
}
func meitiVoice(w http.ResponseWriter, r *http.Request) {
// The upgrade has no body: the signature covers the X-Meiti-Call-Id header (verify() from the chat handler).
if !verify(r.Header.Get("X-Meiti-Signature"), []byte(r.Header.Get("X-Meiti-Call-Id"))) {
w.WriteHeader(http.StatusUnauthorized)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
var call map[string]any // session.started: callId, projectId, from, to, language, contact, resumed?
for {
_, raw, err := conn.ReadMessage()
if err != nil {
return // meiti closed the socket: the call is over
}
var f frame
_ = json.Unmarshal(raw, &f)
switch f.Type {
case "session.started":
_ = json.Unmarshal(raw, &call)
case "prompt": // the caller finished a sentence
answer := myAI(call, f.Text)
switch {
case answer.Done: // spoken, then hang up
conn.WriteJSON(map[string]any{"type": "action", "action": "end", "text": answer.Text})
case answer.NeedsPerson:
conn.WriteJSON(map[string]any{"type": "action", "action": "handover", "text": answer.Text, "note": answer.Reason})
default: // or stream pieces; the last one has "last": true
conn.WriteJSON(map[string]any{"type": "text", "text": answer.Text, "last": true})
}
case "session.ended":
return
}
}
}
func main() {
http.HandleFunc("GET /meiti/voice", meitiVoice)
http.ListenAndServe(":8080", nil)
}
// ASP.NET Core with WebSockets. Serve it behind TLS: meiti only connects to wss:// addresses.
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
app.UseWebSockets();
app.Map("/meiti/voice", async (HttpContext context) =>
{
// The upgrade has no body: the signature covers the X-Meiti-Call-Id header (Verify() from the chat handler).
var callId = context.Request.Headers["X-Meiti-Call-Id"].ToString();
if (!context.WebSockets.IsWebSocketRequest
|| !Verify(context.Request.Headers["X-Meiti-Signature"].ToString(), Encoding.UTF8.GetBytes(callId)))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
using var ws = await context.WebSockets.AcceptWebSocketAsync();
var ct = context.RequestAborted;
Task Send(object frame) =>
ws.SendAsync(new ArraySegment<byte>(JsonSerializer.SerializeToUtf8Bytes(frame)), WebSocketMessageType.Text, true, ct);
var buffer = new byte[64 * 1024];
JsonElement call = default;
while (ws.State == WebSocketState.Open)
{
int count = 0;
ValueWebSocketReceiveResult result;
do
{
result = await ws.ReceiveAsync(buffer.AsMemory(count), ct);
count += result.Count;
} while (!result.EndOfMessage && result.MessageType != WebSocketMessageType.Close);
if (result.MessageType == WebSocketMessageType.Close) break;
using var doc = JsonDocument.Parse(buffer.AsMemory(0, count));
var frame = doc.RootElement;
switch (frame.GetProperty("type").GetString())
{
case "session.started": // callId, projectId, from, to, language, contact, resumed?
call = frame.Clone();
break;
case "prompt": // the caller finished a sentence
var answer = await MyAI.AnswerAsync(call, frame.GetProperty("text").GetString());
if (answer.Done) await Send(new { type = "action", action = "end", text = answer.Text }); // spoken, then hang up
else if (answer.NeedsPerson) await Send(new { type = "action", action = "handover", text = answer.Text, note = answer.Reason });
else await Send(new { type = "text", text = answer.Text, last = true }); // or stream pieces; the last one has last = true
break;
case "session.ended":
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None);
break;
}
}
});
- Setz
last: truean das letzte Stück mit Text – ein leeres Stück wird nicht weitergegeben. - meiti wartet 5 Sekunden auf den Verbindungsaufbau. Kommt keine Verbindung zustande, fehlt die wss-Adresse oder der Schlüssel, hinterlässt der Anrufer eine Nachricht und dein Team sieht den Grund im Verlauf.
- Schließt deine KI die Verbindung mitten im Anruf ohne Aktion, hinterlässt der Anrufer ebenfalls eine Nachricht. Frames, die kein JSON sind, und unbekannte Aktionen ignoriert meiti; ein Frame über 64 KB beendet die Verbindung.
- „Anruf übernehmen“ in der App funktioniert wie bei meitis eigenem Assistenten: Deine KI bekommt
session.ended. Nimmt niemand ab, verbindet meiti den Anrufer wieder mit deiner KI – mitresumed: "handover_failed". - Jeder Satz des Anrufers und jede Antwort deiner KI landen als Verlauf im Postfach.
Skills als Werkzeuge: Weiterleitung und Alarm
Deine KI bringt die Logik, meiti die Technik: Häng auf der Seite des Assistenten unter „Skills“ → „Skill hinzufügen“ Weiterleitungs- und Alarm-Skills an. Sie haben keinen KI-Auslöser – deine KI löst sie selbst über ihren Schlüssel aus. meiti hält dabei die festen Regeln ein: die Zeiten des Skills, die Skill-Limits des Assistenten, deinen Tarif.
| Skill | Was passiert |
|---|---|
forward | Am Telefon stellt meiti den Anruf an das Ziel des Skills durch (Mitarbeiter, Nummer, Rufgruppe – mit seiner Kette „Wenn niemand abnimmt“). Im Chat wird dein Team benachrichtigt wie bei jeder Übergabe; das Gespräch liegt dann beim Team, deine KI bekommt kein message.received mehr, bis es zurückgegeben wird. |
alert | Dein Team bekommt einen Alarm (Push, SMS oder WhatsApp, wie am Skill eingestellt). Der Text ist dein note, sonst dein text, sonst meitis Zeile mit Gespräch und Kontakt. |
| Auslösen | So geht's |
|---|---|
| Chat | In der Antwort (200-Body oder agent-replies): executeSkills: ["buero"] – Schlüssel oder ID als Text, höchstens 5. Erst geht dein text raus, dann laufen die Alarme, die Weiterleitung zuletzt; nur eine Weiterleitung pro Antwort. |
| Telefon | Frame { "type": "skill", "executeSkills": ["buero"], "note"?, "announcement"? } (oder "key" für einen). meiti antwortet je Skill mit skill.result { key, ok, reason }. Nach einer Weiterleitung mit ok: true kommt session.ended; nimmt niemand ab, öffnet meiti eine neue Verbindung mit resumed: "handover_failed" und resumedAfter: { key, ok: false, reason: "no-answer" }. |
| REST | POST /v1/projects/{id}/skills/{key} mit optionalem Body { note, announcement } und optional Idempotency-Key → 200 { key, ok, reason }. Läuft immer wie im Chat – einen laufenden Anruf stellt nur der Telefon-Frame durch. 409 project_not_with_agent, wenn dein Team das Gespräch hat. |
# Run one skill now (chat semantics), e.g. from your own backend. Optional body and Idempotency-Key.
curl https://api.meiti.ai/v1/projects/67890/skills/notdienst \
-H "X-Api-Key: $MEITI_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c2a9e-notdienst" \
-d '{ "note": "Wasser tritt aus der Decke, Kundin ist vor Ort." }'
# 200: { "key": "notdienst", "ok": true }
# 200: { "key": "notdienst", "ok": false, "reason": "outside-hours" }
# 409 project_not_with_agent: your team has the conversation — nothing ran.
// In the phone handler (see above): let your AI decide, meiti does the transfer.
case "prompt": {
const answer = await myAI(call, frame.text, call.skills); // call = session.started, skills listed there
if (answer.forwardTo) {
send({ type: "text", text: answer.text, last: true });
send({ type: "skill", executeSkills: [answer.forwardTo] }); // e.g. "buero"
} else {
send({ type: "text", text: answer.text, last: true });
}
break;
}
case "skill.result": // { key, ok, reason }: tell the caller if a forward could not run
if (!frame.ok) send({ type: "text", text: await myAI.explain(frame.reason), last: true });
break;
// Chat: the 200 answer of your chat endpoint (or the body of POST /v1/projects/{id}/agent-replies).
{
"text": "Ich gebe unserem Notdienst Bescheid und verbinde Sie mit dem Büro.",
"executeSkills": ["notdienst", "buero"],
"note": "Wasser tritt aus der Decke, Kundin ist vor Ort."
}
// Phone: a frame on the WebSocket …
{ "type": "skill", "executeSkills": ["notdienst", "buero"], "note": "Wasser tritt aus der Decke." }
// … and meiti's answer per skill. After an ok forward the call leaves your AI (session.ended).
{ "type": "skill.result", "key": "notdienst", "ok": true }
{ "type": "skill.result", "key": "buero", "ok": true }
// Nobody answered: the caller is back on a new connection.
{ "type": "session.started", "resumed": "handover_failed", "resumedAfter": { "key": "buero", "ok": false, "reason": "no-answer" }, "…": "…" }
- Welche Skills deine KI gerade nutzen darf, steht in
data.skillsvonmessage.receivedund inskillsvonsession.started:{ id, key, name, description, kind, announcement }– nur die, die jetzt laufen dürfen.GET /v1/agentslistet alle mithoursLimitedund den LimitsskillsPerHourLimitundforwardsPerCallLimit. - Ansage: Was meiti dem Kunden sagt, wenn der Skill läuft, legst du am Skill auf der Seite des Assistenten fest (sie steht als
announcementin der Liste – du musst sie nicht selbst sagen).announcementin der Antwort ersetzt sie für diesen Lauf,""unterdrückt sie. Am Telefon spricht meiti ohne eigene Ansage seinen Standardsatz vor dem Durchstellen. - Ergebnis:
agent-repliesantwortet mitskills: [{ key, ok, reason }]. Bei der direkten200-Antwort steht das Ergebnis im Verlauf des nächstenmessage.received– je Skill einsystem-Beitrag mit dem Schlüssel in eckigen und dem Grund in runden Klammern, z. B.… [buero] … (outside-hours). - Gründe (
reason):unknown-skill(kein Skill dieses Assistenten),outside-hours(außerhalb seiner Zeiten),limit-reached(Skill-Limit pro Stunde und Gespräch oder Weiterleitungen pro Anruf),not-allowed(Tarif, Zieltyp, doppelt genannt),no-target,no-recipient,no-answer,one-forward-per-turn,failed.pending-approvalkommt mitok: true: der Alarm wartet auf die Freigabe im Team. - Jeder ausgeführte Skill kostet wie jeder Skill-Lauf; ein durchgestellter Anruf zusätzlich wie jede Weiterleitung. Abgelehnte Skills kosten nichts.
Signatur prüfen
- Dasselbe Verfahren wie bei den API-Webhooks:
X-Meiti-Signature: t=<Unix-Sekunden>,v1=<hex HMAC-SHA256(secret, t + "." + signiert)>. Im Chat ist „signiert“ der unveränderte Body, beim WebSocket der Wert vonX-Meiti-Call-Id. - Lehne Zeitstempel ab, die mehr als 5 Minuten abweichen, und vergleiche in konstanter Zeit – die Beispiele oben tun beides.
- Der Schlüssel beginnt mit
whsec_. Nimm ihn als Text (UTF-8), wie er ist – nicht base64-dekodiert.
Abrechnung und Grenzen
- Im Chat kosten die Antworten deiner KI nichts – wie bei meitis eigener KI. Am Telefon zählt jede angefangene Gesprächsminute mit deiner KI. Was meiti zusätzlich ausführt, etwa Skills zur Nachbearbeitung, kostet wie gewohnt.
- Kommt am Telefon keine Verbindung zu deiner KI zustande, nimmt meiti eine Nachricht auf – das zählt wie ein normal angenommener Anruf, nicht als BYOAI-Minute.
- Kanäle: Telefon, WhatsApp, Website-Chat und Telegram. E-Mails beantwortet ein Assistent mit eigener KI nicht.
handoveram Telefon nimmt eine Nachricht auf. Durchstellen löst deine KI mit einem Weiterleitungs-Skill aus (Skills als Werkzeuge).- Nach dem Gespräch laufen Zusammenfassung und Benachrichtigungen wie bei jedem Assistenten; was während des Gesprächs passiert, entscheidet deine KI – auch, welche Skills sie auslöst.
message.receivedund die Telefon-Frames gehen nur an die Adressen deines Assistenten – nicht an den CRM-Webhook und nicht an API-Webhooks.- Grenzen:
text4000 Zeichen,note500, Antwort-Body und Telefon-Frame je 64 KB,history30 Beiträge, Wartezeit 2–30 Sekunden, Verbindungsaufbau am Telefon 5 Sekunden.
Fragen zur Anbindung?
Wir helfen dir und deiner IT bei der Planung, schauen auf den ersten Entwurf und nehmen Wünsche für die API auf.