Sessions API
The Sessions API puts your agent inside your own product: a chat screen in your app, a support panel in your SaaS dashboard, or a ChatGPT-style list of chats per user. Your app decides when a new chat starts; each chat is a session. Sessions show in the Inbox like conversations from any other channel, with the channel API, so your team can follow and take over.
Base URL: https://peki.ai/api
Auth: Authorization: Bearer psk_… (Authentication)
How it works
- Your backend starts a session for one of your users, identified by your own user id.
- You send that user's messages to the session.
- You get the agent's reply by polling the messages, or by streaming it as it's written.
Sessions always talk to the agent's published version. Changes in the draft reach the API only after you publish.
Endpoints
| Method | Path | Access | What it does |
|---|---|---|---|
POST | /agents/{agentId}/sessions | Read and write | Start a session |
GET | /agents/{agentId}/sessions | Read only | List sessions |
GET | /sessions/{id} | Read only | Get a session |
PATCH | /sessions/{id} | Read and write | Rename a session |
GET | /sessions/{id}/messages | Read only | List messages |
POST | /sessions/{id}/messages | Read and write | Send a message (JSON or stream) |
POST | /sessions/{id}/files | Read and write | Upload a file |
POST | /sessions/{id}/feedback | Read and write | Rate the session |
POST | /sessions/{id}/resolve | Read and write | Resolve the session |
To download a file from a message, use GET /files/{id}.
Objects
Session
{
"id": "671c9f20a1b2c3d4e5f60718",
"agentId": "66f1c2a9e4b0a1d2c3e4f567",
"channel": "api",
"status": "open",
"title": "Where is my order?",
"preview": "Order 10482 still hasn't arrived",
"customer": { "id": "user-1042", "name": "Maria Lopez" },
"createdAt": "2026-10-26T14:03:11.520Z",
"lastMessageAt": "2026-10-26T14:05:40.102Z",
"feedback": "up"
}
| Field | Type | Description |
|---|---|---|
id | string | The session id. |
agentId | string | The agent this session talks to. |
channel | string | api for sessions started here. Other channels (webchat, telegram) appear only when you read one of their conversations by id. |
status | string | open or resolved. |
title | string | The title you set, or the first 60 characters of the first message. Missing until there is one. |
preview | string | The start of the latest customer message (up to 200 characters). |
customer.id | string | Your own user id, as you sent it when starting the session. |
customer.name | string | The customer's name, if known. |
createdAt | string | When the session started. |
lastMessageAt | string | Time of the latest activity in the session. |
feedback | string | The latest rating, up or down. Missing until rated. |
Message
{
"id": "671ca0b3a1b2c3d4e5f60722",
"role": "agent",
"text": "Your order 10482 left our warehouse yesterday and should arrive on Thursday.",
"status": "done",
"createdAt": "2026-10-26T14:05:43.871Z"
}
| Field | Type | Description |
|---|---|---|
id | string | The message id. |
role | string | customer (your user), agent (the AI) or team (a person from your team who took over in the Inbox). |
text | string | The message text. Empty while an agent reply is pending or when it failed. |
status | string | pending (the agent is still writing), done or failed. |
createdAt | string | When the message was created. |
author | string | For team messages: the team member's name. |
attachments | array | Files on the message: { id, name, contentType, size }. Download them with GET /files/{id}. |
Start a session
POST /agents/{agentId}/sessions
Starts a new, empty session for one of your users. Start a new session whenever your app opens a new chat; you don't need one per message.
Body
| Field | Type | Required | Rules |
|---|---|---|---|
customer.id | string | Yes | Your own id for this user. 1–128 characters. The same id always means the same customer in Peki. |
customer.name | string | No | 1–120 characters. Saved the first time Peki sees this customer id; later sessions don't change it. |
title | string | No | 1–120 characters. Without it, the first message becomes the title. |
curl -X POST https://peki.ai/api/agents/66f1c2a9e4b0a1d2c3e4f567/sessions \
-H "Authorization: Bearer $PEKI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"customer": {"id": "user-1042", "name": "Maria Lopez"}, "title": "Order question"}'
const res = await fetch(`https://peki.ai/api/agents/${agentId}/sessions`, {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.PEKI_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ customer: { id: 'user-1042', name: 'Maria Lopez' } }),
});
const session = await res.json(); // { id, agentId, channel: "api", status: "open", ... }
Response 201 with the session.
Errors
| Status | Message | Why |
|---|---|---|
400 | Invalid body | customer.id is missing or a field is too long. |
404 | Agent not found | The agent id is wrong or belongs to another workspace. |
409 | Publish the agent first | The agent has no published version yet. |
Customers from the API are their own customers in Peki, separate from website chat and Telegram customers, even if they're the same person.
List sessions
GET /agents/{agentId}/sessions?customer={customerId}&limit={n}
Lists the agent's API sessions, most recent activity first. Use it to show a user their chat history.
Query parameters
| Parameter | Required | Description |
|---|---|---|
customer | No | Your user id. Only that user's sessions are returned. An unknown id returns an empty list. |
limit | No | How many to return, 1–100. Default 50. |
Only sessions started through the API are listed, not website chat or Telegram conversations, and not automation runs.
curl "https://peki.ai/api/agents/66f1c2a9e4b0a1d2c3e4f567/sessions?customer=user-1042&limit=20" \
-H "Authorization: Bearer $PEKI_API_KEY"
const url = new URL(`https://peki.ai/api/agents/${agentId}/sessions`);
url.searchParams.set('customer', 'user-1042');
const sessions = await (await fetch(url, { headers: { Authorization: `Bearer ${key}` } })).json();
Response 200 with an array of sessions.
There's no pagination cursor: you get at most the 100 most recent sessions. Keep your own list if your users have more.
Get a session
GET /sessions/{id}
curl https://peki.ai/api/sessions/671c9f20a1b2c3d4e5f60718 \
-H "Authorization: Bearer $PEKI_API_KEY"
Response 200 with the session. 404 Session not found if it doesn't exist in your workspace.
This also works for conversation ids from other channels of your workspace, for example an id you received in an automation. You can read them, but only API sessions accept messages.
Rename a session
PATCH /sessions/{id}
| Field | Type | Required | Rules |
|---|---|---|---|
title | string | Yes | 1–120 characters. |
curl -X PATCH https://peki.ai/api/sessions/671c9f20a1b2c3d4e5f60718 \
-H "Authorization: Bearer $PEKI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"title": "Late delivery, order 10482"}'
Response 200 with the updated session.
List messages
GET /sessions/{id}/messages
Returns every message of the session, oldest first: your user's messages, the agent's replies and your team's replies.
curl https://peki.ai/api/sessions/671c9f20a1b2c3d4e5f60718/messages \
-H "Authorization: Bearer $PEKI_API_KEY"
Response 200:
[
{ "id": "671ca0a1a1b2c3d4e5f60720", "role": "customer", "text": "Order 10482 still hasn't arrived", "status": "done", "createdAt": "2026-10-26T14:05:40.102Z" },
{ "id": "671ca0b3a1b2c3d4e5f60722", "role": "agent", "text": "", "status": "pending", "createdAt": "2026-10-26T14:05:40.230Z" }
]
Send a message
POST /sessions/{id}/messages
Sends your user's message to the agent. The agent starts writing its reply right away.
Body
| Field | Type | Required | Rules |
|---|---|---|---|
text | string | One of the two | Up to 4,000 characters. |
attachments | string[] | One of the two | Up to 5 file ids you uploaded to this session with Upload a file. |
A message needs text, files or both. Sending to a resolved session opens it again.
There are two ways to get the reply.
Option 1: JSON and polling
Without a special Accept header, the call returns at once with 202:
{
"message": { "id": "671ca0a1a1b2c3d4e5f60720", "role": "customer", "text": "Order 10482 still hasn't arrived", "status": "done", "createdAt": "2026-10-26T14:05:40.102Z" },
"reply": { "id": "671ca0b3a1b2c3d4e5f60722", "role": "agent", "text": "", "status": "pending", "createdAt": "2026-10-26T14:05:40.230Z" }
}
Then poll GET /sessions/{id}/messages until the message with reply.id has status done (or failed). A poll every 1–2 seconds is enough; most replies take a few seconds, longer when the agent calls tools.
reply is null when nobody from the AI will answer: a person from your team has taken over the conversation in the Inbox. Their answers arrive as team messages.
async function ask(sessionId, text) {
const headers = { Authorization: `Bearer ${process.env.PEKI_API_KEY}`, 'Content-Type': 'application/json' };
const base = 'https://peki.ai/api';
const sent = await (await fetch(`${base}/sessions/${sessionId}/messages`, {
method: 'POST', headers, body: JSON.stringify({ text }),
})).json();
if (!sent.reply) return null; // a team member has taken over
for (let i = 0; i < 60; i++) {
await new Promise((r) => setTimeout(r, 1500));
const messages = await (await fetch(`${base}/sessions/${sessionId}/messages`, { headers })).json();
const reply = messages.find((m) => m.id === sent.reply.id);
if (reply && reply.status !== 'pending') return reply;
}
throw new Error('No reply yet');
}
Option 2: streaming
Send Accept: text/event-stream and the reply streams back on the same connection as server-sent events. Each event is one data: line with a JSON object:
type | Fields | When |
|---|---|---|
accepted | message | First: your user's stored message. |
start | The agent started working. | |
tool | name, state (running, ok or error), durationMs | The agent calls one of its tools. Use it to show "Checking your order…". |
text | text | The reply so far. Each text event carries the whole text up to now, not just the new part: replace what you show, don't append. |
message | message | The final, saved reply. Show this one. |
done | timedOut (only when true) | Last event. The connection closes. |
curl -N -X POST https://peki.ai/api/sessions/671c9f20a1b2c3d4e5f60718/messages \
-H "Authorization: Bearer $PEKI_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-d '{"text": "Where is order 10482?"}'
data: {"type":"accepted","message":{"id":"671ca0a1a1b2c3d4e5f60720","role":"customer","text":"Where is order 10482?","status":"done","createdAt":"2026-10-26T14:05:40.102Z"}}
data: {"type":"start"}
data: {"type":"tool","name":"get_order_status","state":"running"}
data: {"type":"tool","name":"get_order_status","state":"ok","durationMs":412}
data: {"type":"text","text":"Your order 10482 left"}
data: {"type":"text","text":"Your order 10482 left our warehouse yesterday"}
data: {"type":"message","message":{"id":"671ca0b3a1b2c3d4e5f60722","role":"agent","text":"Your order 10482 left our warehouse yesterday and should arrive on Thursday.","status":"done","createdAt":"2026-10-26T14:05:40.230Z"}}
data: {"type":"done"}
const res = await fetch(`https://peki.ai/api/sessions/${sessionId}/messages`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PEKI_API_KEY}`,
'Content-Type': 'application/json',
Accept: 'text/event-stream',
},
body: JSON.stringify({ text: 'Where is order 10482?' }),
});
if (!res.ok) throw new Error(`Peki API ${res.status}: ${await res.text()}`);
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
const events = buffer.split('\n\n');
buffer = events.pop();
for (const e of events) {
if (!e.startsWith('data: ')) continue;
const event = JSON.parse(e.slice(6));
if (event.type === 'text') render(event.text); // the whole reply so far
if (event.type === 'message') render(event.message.text); // final text
}
}
Things to know about streaming:
- No
textevents when the leak guardrail is on. If the agent's Guardrails check replies for leaks before they go out, the text can't stream: you getstart,toolevents and then the checkedmessage. See Guardrails and privacy. - Show the
message, not the lasttext. Guardrails and personal-data handling can change the final reply. Themessageevent is what was saved and what your team sees in the Inbox. - A team takeover ends the stream right after
acceptedwithdone. - Two minutes at most. If the reply isn't finished after 120 seconds, you get
{"type":"done","timedOut":true}and the stream closes. The reply still finishes; fetch it with List messages. - If your connection drops, the reply still finishes. Fetch it with List messages.
- The agent's reasoning is never streamed.
Errors
| Status | Message or code | Why | What to do |
|---|---|---|---|
400 | Write a message or attach a file | Empty text and no attachments. | Send text or files. |
400 | Invalid body | text over 4,000 characters, more than 5 attachments or an id that isn't a file id. | Shorten or fix the body. |
404 | Session not found | Wrong id. | Check the id. |
409 | Only sessions started through the API take messages here | The id is a website chat or Telegram conversation. | Start an API session. |
409 | file_taken | An attachment id doesn't exist, is already attached to another message, belongs to another session, was uploaded by another key, or is older than 24 hours. | Upload it again with the same key. |
429 | This assistant is very busy right now. Please try again later. | The workspace hit its daily limit of live replies. | Retry later. |
503 | Could not accept the message right now. Please try again. | The message wasn't stored. | Retry. |
Upload a file
POST /sessions/{id}/files
Content-Type: multipart/form-data
Uploads one file from your user into the session. Then send it with a message by putting the returned id in attachments. The agent reads it with the message.
Form fields: exactly one part, named file. No other fields.
Accepted files
| Kind | Types |
|---|---|
| Photos | JPEG, PNG, WebP, GIF |
| Documents | PDF, TXT, Markdown, CSV |
- Up to 10 MB per file, 5 files per message and 30 files per conversation.
- The type is checked from the file's content, not its name. SVG and HTML are never accepted.
- Upload and send with the same API key. A file uploaded by one key can't be sent by another.
- A file that isn't sent with a message within 24 hours is deleted.
curl -X POST https://peki.ai/api/sessions/671c9f20a1b2c3d4e5f60718/files \
-H "Authorization: Bearer $PEKI_API_KEY" \
-F "[email protected]"
const form = new FormData();
form.append('file', new Blob([bytes], { type: 'image/jpeg' }), 'damaged-box.jpg');
const file = await (await fetch(`https://peki.ai/api/sessions/${sessionId}/files`, {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.PEKI_API_KEY}` },
body: form,
})).json();
await fetch(`https://peki.ai/api/sessions/${sessionId}/messages`, {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.PEKI_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ text: 'The box arrived damaged', attachments: [file.id] }),
});
Response 201:
{ "id": "671ca1c4a1b2c3d4e5f60730", "name": "damaged-box.jpg", "contentType": "image/jpeg", "size": 284113, "kind": "chat" }
Errors carry a code:
| Status | code | Message |
|---|---|---|
400 | Attach a file in the field "file" | |
400 | file_type | This kind of file is not accepted |
413 | file_size | The file is larger than 10 MB |
400 | conversation_full | This conversation already has 30 files |
409 | Only sessions started through the API take messages here |
Only some models can see images. If your users will send photos, pick a model marked Reads images for the agent. Other models get a note that a photo was sent. See Files and attachments.
Rate the session
POST /sessions/{id}/feedback
Records your user's thumbs up or down for the session. The last rating wins. It shows in Analytics as customer satisfaction.
| Field | Type | Required | Rules |
|---|---|---|---|
rating | string | Yes | up or down. No other fields are allowed. |
curl -X POST https://peki.ai/api/sessions/671c9f20a1b2c3d4e5f60718/feedback \
-H "Authorization: Bearer $PEKI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"rating": "up"}'
Response 200 with the session, its feedback set. 409 Only sessions started through the API take feedback here for other channels.
Resolve the session
POST /sessions/{id}/resolve
Marks the session as resolved, like Resolve in the Inbox. Call it when your user closes the chat or says the problem is solved. If the user writes again in the same session, it opens again.
No body.
curl -X POST https://peki.ai/api/sessions/671c9f20a1b2c3d4e5f60718/resolve \
-H "Authorization: Bearer $PEKI_API_KEY"
Response 200 with the session, status resolved.
Your team and API sessions
- API sessions show in the Inbox with the channel API and your user's name or id.
- When your agent hands off to a person, someone on your team can Take over and reply. Their replies appear in List messages with
roleteamand theauthor's name. Poll for them while the session is taken over:replyisnulland no AI reply comes. - After Hand back to AI, the agent answers again.
A complete example
A small Node.js backend endpoint that your app's chat screen calls:
import express from 'express';
const app = express();
app.use(express.json());
const PEKI = 'https://peki.ai/api';
const AGENT_ID = process.env.PEKI_AGENT_ID;
const headers = { Authorization: `Bearer ${process.env.PEKI_API_KEY}`, 'Content-Type': 'application/json' };
// Your app calls this when the user opens a new chat.
app.post('/chat/sessions', async (req, res) => {
const r = await fetch(`${PEKI}/agents/${AGENT_ID}/sessions`, {
method: 'POST', headers,
body: JSON.stringify({ customer: { id: req.user.id, name: req.user.name } }),
});
res.status(r.status).json(await r.json());
});
// Your app calls this for each message and reads the stream.
app.post('/chat/sessions/:id/messages', async (req, res) => {
const r = await fetch(`${PEKI}/sessions/${req.params.id}/messages`, {
method: 'POST',
headers: { ...headers, Accept: 'text/event-stream' },
body: JSON.stringify({ text: req.body.text }),
});
res.status(r.status).set('content-type', r.headers.get('content-type'));
for await (const chunk of r.body) res.write(chunk);
res.end();
});
app.listen(3000);
Check that req.params.id belongs to the signed-in user before you forward it: the API key can reach every session in your workspace.