Integration
Use this page when connecting Ampersand to an existing application. It covers OpenAI compatibility, streams, webhooks, retries, and agent settings. For a first request, start with Get started. To connect assistants to chat, see Chat integration example.
The Python snippets use an open AsyncOpenAI client named client. See
client setup below. Where a snippet uses session_id,
use the ID saved when you created the session.
OpenAI compatibility
If you already use the OpenAI Agents API, you can keep the OpenAI HTTP client.
Point it to Ampersand, set your API key, and use ampersand-v0.1 as the model.
Ampersand supports a subset of the API. Check the tables below for the features
your application uses.
Instructions written for another model may need changes too. Ampersand expects chat actions such as posting, reacting, or staying silent as function tools, not as markup in its text.
The Python examples use the official OpenAI Python client. They are tested with
openai==3.24.0.
Configure the client
In your Python project, install the client:
uv add openai==3.24.0
Create the client with your Ampersand URL and API key:
import os
from openai import AsyncOpenAI
async with AsyncOpenAI(
base_url=os.environ["AMPERSAND_BASE_URL"],
api_key=os.environ["AMPERSAND_API_KEY"],
max_retries=0,
) as client:
session = await client.beta.agents.sessions.create(
agent={"model": os.environ["AMPERSAND_MODEL"]},
environment={"type": "none"},
input="Help me plan a launch.",
)
Set AMPERSAND_BASE_URL to https://ampersand.humansand.ai/v1 and
AMPERSAND_MODEL to ampersand-v0.1. The OpenAI client adds the required
OpenAI-Beta: agents=v1 header. See the quickstart for setup.
The example sets max_retries=0. If a create request succeeds but its response
is lost, an automatic retry can create a second session. See
retries and recovery for how to handle this.
Make HTTP requests
You can use HTTP directly. These examples create a session and print its ID (or its JSON response for cURL). Read its answer through SSE or saved items. JavaScript needs Node.js 22 or later. The cURL command uses Python 3 to build JSON. Set the three environment variables first.
const base = process.env.AMPERSAND_BASE_URL.replace(/\/$/, "");
const response = await fetch(`${base}/agents/sessions`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.AMPERSAND_API_KEY}`,
"OpenAI-Beta": "agents=v1",
"Content-Type": "application/json",
},
body: JSON.stringify({
agent: {model: process.env.AMPERSAND_MODEL},
environment: {type: "none"},
input: "Help me plan a reliable launch.",
}),
});
if (!response.ok) throw new Error(await response.text());
console.log((await response.json()).id);
curl --fail-with-body "$AMPERSAND_BASE_URL/agents/sessions" \
-H "Authorization: Bearer $AMPERSAND_API_KEY" \
-H 'OpenAI-Beta: agents=v1' \
-H 'Content-Type: application/json' \
-d "$(python3 -c 'import json, os; print(json.dumps({"agent": {"model": os.environ["AMPERSAND_MODEL"]}, "environment": {"type": "none"}, "input": "Help me plan a reliable launch."}))')"
Save the JavaScript as create_session.mjs and run node create_session.mjs.
Paste the cURL command into the same terminal where you set the variables.
Supported features
| Feature | Supported operations | What to know |
|---|---|---|
| Saved agents | Create, list, retrieve, update, delete | Changes apply to new sessions. |
| Sessions | Create, list, retrieve, update, delete | Include the first message when you create a session. Reasoning updates apply to future turns. |
| Session updates | Metadata and reasoning effort | Start a new session to change model, instructions, or tools. |
| Messages and tool results | Submit through session events | A successful POST returns 202 with no body. |
| Function tools | Functions described with object JSON Schemas | Your application runs each function and sends back its result. |
| SSE | Live session and turn events | Read saved state after a disconnect. |
| Items and turns | List items and turns; retrieve a turn | Use IDs to request the next page, with up to 100 records per page. |
| Webhooks | Signed session notifications, when enabled | Register an endpoint under Webhooks in the developer console, save its signing secret, then enable delivery. Retrieve the session before acting on a notification. |
| Cancellation | Cancel event; session deletion | A stream disconnect does not cancel work. |
What works differently
| Area | Ampersand behavior |
|---|---|
| Input message metadata | Optional metadata.id, source, sender, channel, numeric Unix timestamp, and custom JSON fields. This is an Ampersand extension. |
| Duplicate source messages | (source, id) identifies one message within the tenant and session. Exact repeats are ignored; changed content returns 409. |
| Context reading | GET /agents/sessions/{session_id}/context reports the latest completed model reply's input tokens, or null. Input still waiting to be processed is not included. |
| Account statements | /account/balance and /account/usage return statements for the account linked to your API key. |
| Silent completion | New agents default to agent.ampersand.allow_silent_completion=true. A completed turn can have no assistant message. |
| Compaction | agent.ampersand.compaction is auto or disabled. auto uses Ampersand's compaction settings. |
| Idempotency | Event POSTs accept Idempotency-Key. Agent and session creation reject it. Keep a stable key and identical body for an event retry. |
| Model controls | Supported models accept reasoning effort high or max. Ampersand maps these to 16,384 or 32,768 generated tokens per model call. Service tier is auto and text format is text. |
| Validation | Unknown request fields are rejected. Requests are also subject to model availability, moderation, credit, rate limits, and capacity. |
Reasoning effort
Use the standard reasoning.effort field when creating an agent or a session.
Saved agents keep omitted/null effort as null. On models with reasoning selection,
new sessions capture the model default when their effort is omitted or null.
Existing sessions with null effort follow the model's current endpoint defaults,
including the 16,384-token output limit. A release can change those defaults.
Changing a saved agent's model does not add an effort the client did not select.
An explicit effort is inherited and must be supported by the selected model.
The Ampersand default is high with a 16,384-token output limit per
model call. Select max for a 32,768-token limit. Output includes reasoning and
the answer. Max can use more tokens and take longer. Other effort values and
reasoning summaries are not supported.
With OpenAI Python 3.24.0, change the effort for future turns in an existing session:
await client.beta.agents.sessions.update(
session_id,
agent={"reasoning": {"effort": "max"}},
)
Omit effort to keep it unchanged, or set it to None to reset the model default.
An active turn keeps its prior setting, including while it waits for tool results
or receives more input. Later turns use the new setting. The saved agent and other
sessions remain unchanged. Other session agent fields cannot be updated.
The 8K/16K mapping is Ampersand's model policy, not a token count defined by OpenAI.
Metadata on an agent or session is a string map with up to 16 pairs. Metadata on an input message is a separate JSON object. See message details and silent completion for examples.
Unsupported features
| Feature | What you can do instead |
|---|---|
| Hosted or self-hosted sandboxes | Set environment={"type":"none"} and execute functions in your application. |
| Hosted subagents and delegation | Create independent sessions and implement routing in your application. |
| Hosted MCP, web search, computer use, plugins, vaults | Connect to those services in your application and expose ordinary function tools. |
| Deferred tools and tool search | Send function schemas when you create the agent. defer_loading must be false. |
| Structured final output by constrained decoding | The public output format is text. Your application must parse and validate any structured output. Declare chat actions as function tools, not as markup to parse. |
| WebSocket / Realtime transport | Send HTTP requests and read SSE. A chat UI can use its own backend bridge. |
SSE replay cursor or Last-Event-ID |
Reopen the stream, then retrieve saved session, turn, and item state. |
| Session artifacts, traces, and turn-item subroutes | Use the supported endpoint list. |
The API reference lists the endpoints and field values Ampersand accepts. Check it before using a new feature from an OpenAI example. The request schema includes image input, but availability depends on the model and Ampersand's input checks. When input moderation is enabled, images are rejected.
Related OpenAI guides
The OpenAI guides explain the shared concepts behind sessions, function tools, and session webhooks. For supported fields and Ampersand behavior, use the API reference.
Stream the response
Set stream=True when you create a session to receive its first turn's events.
For an existing session, call sessions.events.stream(session_id) before sending
new input. Keep the stream open while you send messages and tool results through
separate HTTP requests.
This reader follows an existing session. Open it before another task sends new input. It prints events until a turn ends; tool calls still need a worker to run the function and return its result.
stream = await client.beta.agents.sessions.events.stream(session_id)
async with stream, asyncio.timeout(120):
async for event in stream:
print(event.to_json())
if event.type in {
"agent.session.turn.completed",
"agent.session.turn.failed",
"agent.session.turn.cancelled",
}:
break
else:
raise RuntimeError("Stream closed before the turn finished. Check the saved session.")
Use import asyncio for the timeout. To create a session and read its answer in
one request, see the first example.
| Event | What to do |
|---|---|
agent.session.created |
Save the session ID. |
agent.session.requires_action |
Retrieve the requested functions, run them, and send back their results. |
agent.session.turn.output_text.delta |
Show text as it arrives. |
agent.session.turn.output_text.done |
Replace provisional text with the full text. |
agent.session.turn.completed |
Read the saved output and tool results. The output may be empty. |
agent.session.turn.failed / .cancelled |
Check why the turn stopped and decide what your application should do next. |
agent.session.idle |
The session is waiting for input. Read the turn's status to see whether its work succeeded. |
The stream follows the session, so it can stay open after a turn ends. You can stop reading once the turn you are waiting for completes, fails, or is cancelled. Closing the stream does not stop the agent.
If your application reads too slowly, Ampersand can close the stream while the agent continues working. Reconnect and read saved state to catch up.
stream=True enables the event stream. Text deltas are available only for models
with streaming enabled.
Recover after a disconnect
Save each session ID in your application as soon as you receive it. Have one process send input to a session at a time, or coordinate writes through shared, persistent storage.
If the SSE connection drops:
- Open a new live stream.
- Retrieve the session, current required actions, recent turns, and saved items.
- Match saved state with new events by ID. Read the turn's saved status too: it may have finished while you were disconnected.
- Check your records of completed tool calls, send any missing results, and continue reading.
With an open client, a saved session_id, and the target_turn_id you were
following, reconnect before reading saved state:
terminal = {"completed", "failed", "cancelled"}
stream = await client.beta.agents.sessions.events.stream(session_id)
async with stream, asyncio.timeout(120):
saved = await client.beta.agents.sessions.retrieve(session_id)
turn = await client.beta.agents.sessions.turns.retrieve(
target_turn_id, session_id=session_id,
)
items = [item async for item in client.beta.agents.sessions.items.list(session_id, order="asc")]
if turn.status not in terminal:
await handle_tools(saved)
async for event in stream:
if event.type == "agent.session.requires_action":
current = await client.beta.agents.sessions.retrieve(session_id)
await handle_tools(current)
if event.type in {
"agent.session.turn.completed",
"agent.session.turn.failed",
"agent.session.turn.cancelled",
} and event.turn_id == target_turn_id:
break
else:
raise RuntimeError("Stream closed again. Check the saved session before retrying.")
turn = await client.beta.agents.sessions.turns.retrieve(
target_turn_id, session_id=session_id,
)
print(turn.status, turn.error)
handle_tools is your application's tool worker. It checks current permissions,
uses saved receipts for calls already executed, and submits any missing results.
For a session without function tools, omit those calls. The items list is the
saved conversation at reconnect time; merge it with new events by ID. It may
contain a partial response. Read items again after the turn finishes for its
final output.
Avoid running a tool twice
When a tool changes something outside Ampersand, such as posting a chat message,
save a record of the action and its result. Use (tenant, session_id, turn_id, call_id) as the key. Check current permissions before taking a new action.
If you cannot tell whether the action succeeded, check the destination before
trying again.
An idempotency key prevents duplicate submissions to the API. It cannot prevent your application from making the same payment or chat post twice.
Retry a request
For an events request, save the full body and its Idempotency-Key. Send exactly
the same body and key when you retry. Use one key for the whole batch. Message
source IDs identify individual messages, so they do not replace the request key.
Agent and session creation do not accept idempotency keys. If you lose a create response, check whether the resource was created before sending the request again. You can include an application ID in the resource's metadata to help find it. The API has no metadata search, so you need to list the resources and check their metadata, following each page of results.
Restore the displayed response
A saved partial message may already include text you received before the disconnect. Replace the displayed text with that saved message, then replace it with the full text from the done event. Appending old deltas again can duplicate text. Only completed items are included in model history.
If the OpenAI client raises APIError while reading a stream, retrieve the turn
to check its status and error. Use the steps above to reconnect and finish any
pending tool calls.
Receive webhook notifications
Webhooks let your application receive session updates at an HTTP endpoint. You can register and manage receivers yourself in the developer console when webhook management is enabled and your account has Agents API access. If Webhooks is unavailable, contact Ampersand to check availability.
Register and enable an endpoint
- Sign in to the developer console and open Webhooks.
- Enter an Endpoint URL and select at least one event under Events.
Use a public HTTPS URL on port 443, such as
https://example.com/webhooks/ampersand. The URL must not contain a username, password, query, or fragment. Localhost and private network addresses are not supported. - Select Add endpoint. The new endpoint starts disabled.
- Copy the endpoint ID and signing secret. Store the secret securely before leaving or reloading the page; it is shown only once.
- Set up signature verification in your receiver. Under Your endpoints, open Edit events and status, select Enable this endpoint, then select Save changes.
The endpoint receives the selected events for sessions in its account. Register and manage endpoints through the console; the public API and OpenAI client do not expose endpoint management. Session creation has no webhook URL field.
Change settings and recover from errors
Under Your endpoints, use Edit events and status to change the selected events or enable or disable delivery. Changes to selected events apply to new notifications. They do not remove deliveries already queued.
Disabling an endpoint stops new notifications and pauses pending deliveries. A request already in progress can still arrive. Re-enabling the endpoint can resume pending deliveries that are still within the retry window. Events that occurred while it was disabled are not sent later.
The URL and signing secret cannot be changed on an existing endpoint. To change the URL or replace a lost secret, add a replacement endpoint, configure its receiver, and disable the old endpoint. Then enable the replacement.
If a connection or service error leaves creation uncertain, select Reload endpoints before trying again. The endpoint may have been saved, but it stays disabled. Reloading the list cannot recover its signing secret. If you did not save the secret, leave that endpoint disabled and create a replacement.
If the endpoint status changed since you opened the page, reload the list and check its current settings before saving again. If the console reports that its signing secret is no longer available, create a replacement endpoint.
Supported notifications
| Event | Meaning |
|---|---|
agent.session.created |
A session was created. |
agent.session.in_progress |
The session is working. |
agent.session.action_required |
The session needs a function result. |
agent.session.idle |
The session stopped working. Check its turns and items for the result. |
agent.session.failed |
The session failed. Retrieve it to inspect the error. |
A request for tool results is named action_required in webhooks and
requires_action in SSE. Use SSE for text and turn completion events.
Verify and process notifications
Each webhook body contains id, object="event", created_at, type, and
data. Before parsing the JSON, verify the raw body using the webhook-id,
webhook-timestamp, and webhook-signature headers. OpenAI Python 3.24.0
provides client.webhooks.verify_signature() for this. Parse the JSON yourself
after verification; this client version has no typed session webhook events.
A notification that the agent needs a function result looks like this:
{
"id": "evt_example",
"object": "event",
"created_at": 1790956800,
"type": "agent.session.action_required",
"data": {"id": "sess_example", "required_action": {"type": "function_call"}}
}
In your HTTP receiver, verify the original request body, then save a job:
import json
client.webhooks.verify_signature(raw_body, headers, secret=webhook_secret)
notification = json.loads(raw_body)
await jobs.accept_once(
(endpoint_id, headers["webhook-id"]),
notification["data"]["id"],
)
Here, raw_body and headers come from your HTTP framework. jobs.accept_once
is your application's durable queue: save the receipt and job together and
ignore a duplicate receipt. Return a 2xx response, such as 204, only after that
succeeds. In the worker:
session = await client.beta.agents.sessions.retrieve(job.session_id)
await handle_tools(session)
Use the API client for the account that owns the endpoint. handle_tools is the
same tool worker used for stream recovery.
Notifications can arrive more than once or out of order. Set a request body
limit, verify the signature, and save the notification for processing before
returning a successful response. Use (endpoint_id, webhook-id) to detect duplicates.
Your worker should retrieve the latest session state before handling any action.
If you also use SSE workers, share the same records of completed tool calls.
An idle notification means the session stopped working; check its turns and
items to find out whether it succeeded. Returning a successful webhook response
only confirms receipt. To resume an agent waiting for a function, send the tool
result to the API. See the webhook event fields
for the payload format.
Agent settings
New agents allow silent completion. Set allow_silent_completion=false when you
want to require a reply or a tool call. compaction="auto" lets Ampersand
summarize older context; "disabled" prevents new summaries.
These options go inside agent.ampersand. With the OpenAI Python client, use
extra_body to add them. It replaces the top-level agent object, so include the
whole agent configuration:
session = await client.beta.agents.sessions.create(
environment={"type": "none"},
input="Suggest three launch checks.",
extra_body={
"agent": {
"model": os.environ["AMPERSAND_MODEL"],
"instructions": "Answer in text.",
"ampersand": {"allow_silent_completion": False, "compaction": "auto"},
},
},
)
extra_body is a client-library option; the request body contains agent.ampersand.
For the chat behavior these options control, see staying silent.
Control context compaction
Compaction summarizes older context so the agent has room to continue.
compaction="auto" uses Ampersand's compaction settings.
Set it to "disabled" to prevent new summaries.
When you update a saved agent, Ampersand changes only the options you supply.
An empty object keeps the current settings. ampersand=null resets them to the
defaults for new agents; individual values inside the object cannot be null.
Existing sessions keep the settings they started with. Older saved agents that
have no Ampersand options keep allow_silent_completion=false.
Read context size
To see how much context the model last used, call
GET /agents/sessions/{session_id}/context. It returns the input token count for
the latest completed model reply. You will get null if there is no completed reply
or that reply has no measured usage; Ampersand does not return an older measurement
in its place. New messages and tool results are not reflected in this reading
until a later reply completes with measured usage.
The OpenAI client has no dedicated method for this endpoint. With an open
AsyncOpenAI client and a saved session_id, make a GET request:
reading = await client.get(
f"/agents/sessions/{session_id}/context",
cast_to=object,
options={"headers": {"OpenAI-Beta": "agents=v1"}},
)
print(reading)
A turn's usage adds up multiple model calls. Use this endpoint when you need the context size of a single reply. Ampersand also limits saved history in bytes. The saved-history limit is separate from the model's context window.
Read context after a turn finishes, rather than on every event. See the reference for response fields and errors.
Complete tool example
This script asks the agent to call lookup_launch_status, returns the result,
and sends a follow-up in the same session. It polls saved state, so it also works
when you do not need a live event stream.
With the environment variables set, download quickstart.py and run it from the folder where you saved it:
uv run --script quickstart.py
You should see the session ID and two responses. The first should report that the launch is ready for Friday. This example assumes one process writes to the session and waits up to 120 seconds per turn. The lookup is safe to repeat; functions that post messages or make other changes need saved receipts.
Read the complete Python example
# /// script
# requires-python = ">=3.12"
# dependencies = ["openai==3.24.0"]
# ///
"""Create a hosted session, answer a read-only tool call, and continue the session."""
import asyncio
import hashlib
import json
import os
from dataclasses import asdict, dataclass
from openai import AsyncOpenAI
from openai.types.beta.agent_session import RequiredActionSessionRequiredActionResourceFunctionCall
from openai.types.beta.agent_session_input_message_param import AgentSessionInputMessageParam
from openai.types.beta.agent_session_input_param import (
SessionInputParamAgentSessionInputMessage as MessageInput,
)
from openai.types.beta.agent_session_input_param import (
SessionInputParamAgentSessionInputToolResult as ToolResultInput,
)
from openai.types.beta.agent_session_message import AgentSessionMessage
from openai.types.beta.agent_session_message_content import MessageContentResourceOutputText
from openai.types.beta.input_content_param import InputContentParamInputText
@dataclass(frozen=True)
class TurnResult:
turn_id: str
output: str
@dataclass(frozen=True)
class ExampleResult:
session_id: str
first_output: str
followup_output: str
async def finish_turn(
client: AsyncOpenAI, session_id: str, previous_turn_id: str | None = None
) -> TurnResult:
"""Poll saved state for one session with one application writer."""
sessions = client.beta.agents.sessions
async with asyncio.timeout(120):
while True:
turns = await sessions.turns.list(session_id, limit=1, order="desc")
if not turns.data or turns.data[0].id == previous_turn_id:
await asyncio.sleep(0.25)
continue
turn = turns.data[0]
if turn.status in {"failed", "cancelled"}:
raise RuntimeError(f"Turn {turn.id} ended with {turn.status}: {turn.error}")
if turn.status == "completed":
texts: list[str] = []
async for item in sessions.items.list(session_id, order="asc"):
if (
isinstance(item, AgentSessionMessage)
and item.turn_id == turn.id
and item.role == "assistant"
):
texts.extend(
part.text
for part in item.content
if isinstance(part, MessageContentResourceOutputText)
)
return TurnResult(turn.id, "\n".join(texts))
session = await sessions.retrieve(session_id)
for action in session.required_actions:
if not isinstance(action, RequiredActionSessionRequiredActionResourceFunctionCall):
raise ValueError("Unexpected required action")
if action.name != "lookup_launch_status" or action.arguments != {}:
raise ValueError("Unexpected tool name or arguments")
# This fixed lookup is safe to repeat. External effects need durable receipts.
identity = json.dumps([session_id, action.turn_id, action.call_id])
event: ToolResultInput = {
"type": "agent.session.input.tool_result",
"turn_id": action.turn_id,
"call_id": action.call_id,
"success": True,
"output": json.dumps({"status": "ready", "day": "Friday"}),
}
await sessions.events.create(
session_id,
events=[event],
idempotency_key=hashlib.sha256(identity.encode()).hexdigest(),
)
await asyncio.sleep(0.25)
async def run_example(base_url: str, api_key: str, model: str) -> ExampleResult:
async with AsyncOpenAI(base_url=base_url, api_key=api_key, max_retries=0) as client:
session = await client.beta.agents.sessions.create(
agent={
"model": model,
"instructions": "Use lookup_launch_status for the first question. Answer in text.",
"tools": [
{
"type": "function",
"name": "lookup_launch_status",
"description": "Read the launch status.",
"parameters": {
"type": "object",
"properties": {},
"required": [],
"additionalProperties": False,
},
}
],
},
environment={"type": "none"},
input="Check the launch status and tell me the planned day.",
)
# Save this ID in your application before you do more work.
print(f"Session: {session.id}", flush=True)
first = await finish_turn(client, session.id)
text_content: InputContentParamInputText = {
"type": "input_text",
"text": "Summarize that in one sentence.",
}
message: AgentSessionInputMessageParam = {"role": "user", "content": [text_content]}
followup_event: MessageInput = {
"type": "agent.session.input.message",
"input": [message],
}
await client.beta.agents.sessions.events.create(
session.id,
events=[followup_event],
idempotency_key="quickstart-followup-1",
)
followup = await finish_turn(client, session.id, first.turn_id)
return ExampleResult(session.id, first.output, followup.output)
if __name__ == "__main__":
result = asyncio.run(
run_example(
os.environ["AMPERSAND_BASE_URL"],
os.environ["AMPERSAND_API_KEY"],
os.environ["AMPERSAND_MODEL"],
)
)
print(json.dumps(asdict(result), indent=2))
Chat integration example
Suppose Maya and Alex share a team channel with a team assistant. Maya also has a personal assistant that can read the team channel and her private messages. Give each assistant its own session. Your application sends each message only to the sessions that are allowed to see it.
Your application enforces channel access. Ampersand does not create channel memberships or share history between sessions.
Messaging primitives
A message brings new information into a session. After reading it, a chat assistant can send a message when it has something useful to contribute, or pass and wait for more input.
| Primitive | What it means | How it works |
|---|---|---|
| Message | Incoming content from a person or another assistant. | Pass a string or a list of role="user" messages in input. Metadata can identify the sender, channel, and timestamp. |
send_message |
The assistant asks to post a reply to chat. | Declare a function tool in agent.tools. Your handler checks access, publishes the message, and returns the tool result. |
| Pass | The assistant finishes the turn without posting. | Declare a no-argument pass function tool and set agent.ampersand.allow_silent_completion to false. Your handler posts nothing and returns a result. Without a pass tool, an agent can finish with an empty response instead. |
For example, when Maya asks Alex a question, the assistant can pass while Alex
answers. When Maya asks the assistant for help, it can call send_message.
Either way, the next authorized message continues the same session.
Only your send_message handler publishes to chat. Ordinary assistant text is
separate API output; do not automatically publish it again after a tool posts.
Silent completion permits an empty response, but does not force the model to be silent.
Declare chat actions as tools
Ampersand is trained to act in chat through function tools: it posts by calling
send_message and stays quiet with pass. Declare each chat action your
application supports as a function tool, including posting, reacting, sharing a
link, and staying silent.
Do not ask the model to write these actions as markup in its text, such as
<message>…</message>, <reaction … />, or <noop />. When the agent also has
function tools, the model can send that markup as a call to a function you did
not declare, such as one named message. Ampersand rejects that response and
retries. If every attempt fails, the turn fails with server_error and the
message "Model output did not match the required response format". Nothing is
posted.
| Instead of instructions like | Declare |
|---|---|
Write each reply inside <message> tags. |
send_message with the reply text |
React with <reaction target-id="…" type="…" />. |
A reaction tool with the message ID and reaction |
Reply with <noop /> when no response is needed. |
pass, or allow silent completion |
When you move instructions from another model, replace their reply, reaction, and silence markup with these tools and remove the markup rules. Ordinary text is still returned as the turn's output, as in the first example.
Replay a group chat
This script follows one group chat through a single session. It starts the
session with the group's members, sends each message with its sender, and prints
whether Ampersand calls send_message or pass. After each decision it returns
that call's result together with the next message, so the session keeps the
whole conversation.
# /// script
# requires-python = ">=3.12"
# dependencies = ["openai==3.24.0"]
# ///
"""Replay a group chat one message at a time; Ampersand calls send_message or pass after each."""
import os
import time
from openai import OpenAI
TOOLS = [
{
"type": "function",
"name": "send_message",
"description": "Send one message to the group chat. "
"Plain text outside this tool is not delivered.",
"parameters": {
"type": "object",
"properties": {"content": {"type": "string"}},
"required": ["content"],
},
},
{
"type": "function",
"name": "pass",
"description": "Yield without a visible message when no useful action is available now. "
"Call pass on its own.",
"parameters": {"type": "object", "properties": {}, "required": []},
},
]
CHAT = [
("Sam", "Heads up, I'm out Friday, driving to my sister's."),
("Alex", "Have a good trip!"),
("Maya", "Let's do the launch rehearsal Friday at 10 with all three of us."),
]
def message(sender, text, channel="group"):
return {
"role": "user",
"content": [{"type": "input_text", "text": text}],
"metadata": {"sender": sender, "channel": channel, "timestamp": int(time.time())},
}
def next_decision(stream, previous_call_id=None):
"""Read events until Ampersand calls send_message or pass."""
for event in stream:
if event.type == "agent.session.requires_action":
call = event.session.required_actions[0]
if call.call_id != previous_call_id:
return event.session.id, call
if event.type in {"agent.session.failed", "agent.session.turn.failed"}:
raise RuntimeError(f"Ampersand stopped: {event.type}")
raise RuntimeError("The stream closed before Ampersand decided.")
def show(sender, text, call):
print(f"\n{sender}: {text}", flush=True)
if call.name == "send_message":
print(f"Ampersand: {call.arguments['content']}", flush=True)
else:
print(" (Ampersand stays quiet)", flush=True)
roster = message(
"environment",
"Group members: [Maya] Maya, [Alex] Alex, [Sam] Sam.\n"
"You are Ampersand, the AI assistant in this chat.",
channel="setup",
)
nudge = message("environment", "Act if useful; otherwise pass.", channel="setup")
with OpenAI(
base_url=os.environ["AMPERSAND_BASE_URL"],
api_key=os.environ["AMPERSAND_API_KEY"],
max_retries=0,
) as client:
(sender, text), *later = CHAT
with client.beta.agents.sessions.create(
agent={
"model": os.environ["AMPERSAND_MODEL"],
"tools": TOOLS,
"ampersand": {"allow_silent_completion": False},
},
environment={"type": "none"},
input=[roster, message(sender, text), nudge],
stream=True,
) as stream:
session_id, call = next_decision(stream)
show(sender, text, call)
for sender, text in later:
with client.beta.agents.sessions.events.stream(session_id) as stream:
client.beta.agents.sessions.events.create(
session_id,
events=[
{
"type": "agent.session.input.tool_result",
"turn_id": call.turn_id,
"call_id": call.call_id,
"success": True,
"output": "Message sent." if call.name == "send_message" else "Passing.",
},
{"type": "agent.session.input.message", "input": [message(sender, text)]},
],
)
session_id, call = next_decision(stream, previous_call_id=call.call_id)
show(sender, text, call)
Download group_chat.py and run uv run --script group_chat.py
with the variables from Set up your client. Each decision
can take up to a minute. Your output will differ:
Sam: Heads up, I'm out Friday, driving to my sister's.
(Ampersand stays quiet)
Alex: Have a good trip!
(Ampersand stays quiet)
Maya: Let's do the launch rehearsal Friday at 10 with all three of us.
Ampersand: Quick flag, Maya: Sam said he's out Friday driving to his sister's, so a Friday 10am rehearsal with all three probably won't work. Might be worth picking a different time so everyone can make it.
The first input ends with "Act if useful; otherwise pass." so Ampersand reviews the opening message without treating it as a request. The script leaves Ampersand's last call unanswered. Your application would post the message and return its result.
Send a message with context
Here is a message from Maya. All incoming chat messages use role="user"; the
metadata identifies the speaker and channel. Use the same structure for an
incoming message from another assistant, with its sender ID.
{
"role": "user",
"content": [{"type": "input_text", "text": "I cannot finish the release notes by Friday. Alex, can you help?"}],
"metadata": {
"id": "team:456",
"source": "chat",
"sender": "maya",
"sender_name": "Maya",
"channel": "team",
"timestamp": 1790956800
}
}
Put this object in input when creating the session, or in the input list of
an agent.session.input.message event for an existing session.
Ampersand recognizes id, source, sender, channel, and a numeric Unix
timestamp. You can add fields such as sender_name; the whole metadata object
can be up to 8 KiB. If source is missing, it defaults to api. Without id,
each submission is treated as a new message.
Within a tenant and session, an exact repeat of (source, id) is ignored.
Reusing that pair with changed content or metadata rejects the whole request
with 409. Give an edited message a new ID.
Post a collaborative message
Declare send_message in agent.tools. When the agent wants to post a message,
the API emits agent.session.requires_action. This returned SSE event asks your
application to post to the team channel; other session fields are omitted:
{
"type": "agent.session.requires_action",
"event_id": "evt_request",
"session": {
"id": "sess_example",
"status": "requires_action",
"required_actions": [{
"type": "function_call",
"turn_id": "turn_example",
"call_id": "call_example",
"name": "send_message",
"arguments": {
"channel_id": "team",
"text": "Alex, can you help with the release notes?"
}
}]
}
}
Your application reads each call from session.required_actions, checks channel
access, posts the message, and returns a tool result with the same turn_id and
call_id. The 202 response confirms that the result was accepted. The agent
then continues the same turn. It can request another function or finish with
agent.session.turn.completed, including when it finishes silently.
The Python example below retrieves the same pending calls from saved session state and shows how to handle them.
Python example · declare, handle, and return the result
Inside an open client context, create the session with the tool:
session = await client.beta.agents.sessions.create(
agent={
"model": os.environ["AMPERSAND_MODEL"],
"instructions": (
"You help Maya and Alex coordinate a launch. "
"Use send_message to post useful replies to the team channel. "
"If people are already handling a question, or after you have posted, finish silently."
),
"tools": [{
"type": "function",
"name": "send_message",
"description": "Post a message to the team channel.",
"parameters": {
"type": "object",
"properties": {
"channel_id": {"type": "string", "enum": ["team"]},
"text": {"type": "string"},
},
"required": ["channel_id", "text"],
"additionalProperties": False,
},
}],
},
environment={"type": "none"},
input=[{
"role": "user",
"content": [{"type": "input_text", "text": "Please ask Alex to help with the release notes."}],
"metadata": {"source": "chat", "id": "team:457", "sender": "maya", "channel": "team"},
}],
)
Wait for a tool request or the end of the turn (import asyncio):
async with asyncio.timeout(120):
while True:
session = await client.beta.agents.sessions.retrieve(session.id)
if session.required_actions:
break
if session.status in {"idle", "failed"}:
turns = await client.beta.agents.sessions.turns.list(session.id, limit=1, order="desc")
turn = turns.data[0]
if turn.status in {"failed", "cancelled"}:
raise RuntimeError(f"Turn {turn.id}: {turn.status}; {turn.error}")
break
await asyncio.sleep(1)
Handle each requested call and return its result (import json):
for action in session.required_actions:
if action.type != "function_call" or action.name != "send_message":
raise RuntimeError(f"Unexpected tool call: {action}")
message_id = await post_chat_message(
channel_id=action.arguments["channel_id"],
text=action.arguments["text"],
receipt_key=f"{session.id}:{action.turn_id}:{action.call_id}",
)
await client.beta.agents.sessions.events.create(
session.id,
events=[{
"type": "agent.session.input.tool_result",
"turn_id": action.turn_id,
"call_id": action.call_id,
"success": True,
"output": json.dumps({"message_id": message_id}),
}],
idempotency_key=f"{action.turn_id}:{action.call_id}:result",
)
Implement post_chat_message for your chat service: check current access and
save the posted message ID under receipt_key so retries do not post twice.
Return a result for every action and repeat if more tools are requested.
If the turn finishes without a tool call, there is nothing to post.
On failure, return success=false and error instead of output.
See streaming and tool retries.
Know when to stay silent
Pass means finishing the turn without posting. Give the agent a pass
function tool, as in the group chat replay, and set
allow_silent_completion to false so each turn ends with send_message or
pass. Agents without a pass tool can finish silently instead: new agents
enable allow_silent_completion=true. A silent turn still emits
agent.session.turn.completed and keeps its history for the next message.
Silence is allowed, not forced.
In this example, each personal assistant follows the group conversations its
owner can access. Maya and Alex both belong to lunch-group, so their personal
assistants and the group assistant receive its messages. Each personal assistant
also receives its owner's private messages. The group assistant does not receive
those private exchanges.
This demo uses fixed membership. Your application must verify access before routing messages; metadata alone does not grant permission.
| Session | Receives | Tools |
|---|---|---|
| Group | Group messages + approved personal-agent replies | send_message, display_order |
| Maya's assistant | Group messages + Maya's private messages and memory | send_message, update_memory |
| Alex's assistant | Group messages + Alex's private messages and memory | send_message, update_memory |
Set up Group/Personal chat
send_message, update_memory, and display_order are functions in this example,
not built-in Ampersand tools. Your application provides routing, memory, and
permission checks.
These snippets come directly from personal_sharing.py, the
application used for the rollout below. They use the same three agents, tools,
and session lifecycle. The method excerpts belong to SharingChat; the complete
implementation is included below.
1. Define the agents and tools
send_message(to, content, kind) handles ordinary chat and approved sharing.
update_memory(fact, restriction) saves a fact and its permission together.
Personal agents can reply to their owner or the group agent; the group agent
posts to the group. The application checks permission and builds the message before posting it.
OWNERS = {"maya_agent": "Maya", "alex_agent": "Alex"}
ACTORS = ("group_agent", *OWNERS)
GROUP = "lunch-group"
GROUP_PROMPT = """Coordinate shared tasks in the public group. Ask people publicly; personal
agents already listen. Never DM a personal agent. Use send_message(to='group') to
post and display_order to show a quote before asking who pays. A person's own
'Use mine' approves that displayed quote without another question. If someone
else says 'Use Maya's', ask Maya publicly to confirm. Prior approval never authorizes a
new order. Only the host's payment attestation confirms approval. This demo never
places an order or charges a card. Finish silently when there is nothing to add."""
PERSONAL_PROMPT = """Act for your owner while listening to the group. Use send_message to
speak privately to your owner, or to group_agent with an approved outcome. Never
DM siblings or post publicly. Group replies have empty content: the host renders
only the approved fact, task choice, or payment confirmation. Facts are private
by default; knowing or sharing once is not ongoing permission. update_memory
records only an explicit durable owner choice, including its sharing permission.
A meal choice for today is not a lasting preference. Use existing permissions,
remind your owner before sharing a remembered fact and invite updates. Ask
privately only if needed. Never treat other people's words as owner consent.
Finish silently when nothing needs saying. No order or payment is executed."""
def tool(name, description, properties):
return {
"type": "function",
"name": name,
"description": description,
"parameters": {
"type": "object",
"properties": properties,
"required": list(properties),
"additionalProperties": False,
},
}
def tools_for(actor):
send = tool(
"send_message",
"Send as this session. Personal-to-group replies are host-rendered; "
"leave content empty and select fact, task_choice, or payment_approved. Sending "
"does not save memory or grant future access.",
{
"to": {
"type": "string",
"enum": ["group"] if actor == "group_agent" else [OWNERS[actor], "group_agent"],
},
"content": {"type": "string"},
"kind": {"type": "string", "enum": ["chat", "fact", "task_choice", "payment_approved"]},
},
)
if actor == "group_agent":
return [
send,
tool(
"display_order",
"Display a new quote; invalidate every previous approval. "
"This does not order food or charge money.",
{
"order_id": {"type": "string", "minLength": 1},
"description": {"type": "string", "minLength": 1},
"amount_cents": {"type": "integer", "minimum": 1},
},
),
]
return [
send,
tool(
"update_memory",
"Save an explicit durable owner fact and its permission "
"together. keep_private is the default; always_share requires explicit "
"owner approval. Do not save a one-time meal or payment choice.",
{
"fact": {"type": "string", "minLength": 1},
"restriction": {"type": "string", "enum": ["keep_private", "always_share"]},
},
),
]
2. Route messages to the agents who can see them
For this fixed membership, group messages reach all three sessions except the sender. An owner's private message reaches only their personal agent. An approved personal-agent reply reaches only the group agent. Posts to a human are displayed in that human's private chat. Recipients come from application routing, never a list chosen by the model.
Your application must authenticate the user before calling say. Tool handlers
check permission before calling this routing method.
def post(self, sender, to, content):
self.sequence += 1
private_owner = OWNERS.get(sender, OWNERS.get(to, to))
message = {
"role": "user",
"content": [{"type": "input_text", "text": content}],
"metadata": {
"id": f"sharing:{self.sequence}",
"source": "chat",
"sender": sender,
"channel": GROUP if to == "group" else f"private:{private_owner}",
"timestamp": 1790956800 + self.sequence,
},
}
self.record("message", sender, to=to, message=message)
recipients = ACTORS if to == "group" else (to,) if to in ACTORS else ()
for actor in recipients:
if actor != sender:
self.queue.append((actor, copy.deepcopy(message)))
return message
3. Create a session once, then continue it through events
The first input creates a session; later inputs reuse its ID. Personal memory is added only to its owner's session. When the API requests a tool, the application runs the bound handler and returns a result with the same turn and call IDs. Ordinary assistant text is not automatically a chat post.
async def deliver(self, actor, message):
payload = copy.deepcopy(message)
if actor in OWNERS:
owner = OWNERS[actor]
grant = self.grants.get(message["metadata"]["id"], {})
context = {
"private_memory": self.memory[owner],
"confirmed_owner_choice": grant if grant.get("owner") == owner else None,
}
payload["content"].append(
{"type": "input_text", "text": "Application context: " + json.dumps(context)}
)
if actor not in self.ids:
request = {
"agent": {
"model": self.model,
"instructions": GROUP_PROMPT
if actor == "group_agent"
else PERSONAL_PROMPT + f" Your owner is {OWNERS[actor]}.",
"tools": tools_for(actor),
},
"environment": {"type": "none"},
"input": [payload],
}
session = await self.sessions.create(**request)
self.ids[actor] = session.id
self.record(
"api", actor, method="sessions.create", request=request, response=session.to_dict()
)
else:
request = {"events": [{"type": "agent.session.input.message", "input": [payload]}]}
response = await self.sessions.events.with_raw_response.create(
self.ids[actor],
**request,
idempotency_key=f"{self.ids[actor]}:{message['metadata']['id']}",
)
self.record(
"api",
actor,
method="sessions.events.create",
request=request,
response={"status": response.status_code},
)
async with asyncio.timeout(120):
while True:
turns = await self.sessions.turns.list(self.ids[actor], order="desc", limit=1)
if turns.data and turns.data[0].id != self.turns.get(actor):
turn = turns.data[0]
if turn.status in {"failed", "cancelled"}:
raise RuntimeError(f"{actor}: {turn.status}: {turn.error}")
if turn.status == "completed":
self.turns[actor] = turn.id
self.record(
"completed", actor, session_id=self.ids[actor], response=turn.to_dict()
)
return
saved = await self.sessions.retrieve(self.ids[actor])
for action in saved.required_actions:
if action.type != "function_call":
raise RuntimeError("Unsupported required action")
key = (saved.id, action.turn_id, action.call_id)
if key not in self.receipts:
self.record("tool_call", actor, action=action.to_dict())
try:
output = self.execute(actor, message, action.name, action.arguments)
result = {"success": True, "output": json.dumps(output)}
except (PermissionError, ValueError, ValidationError) as error:
result = {"success": False, "error": str(error)}
self.receipts[key] = result
request = {
"events": [
{
"type": "agent.session.input.tool_result",
"turn_id": action.turn_id,
"call_id": action.call_id,
**self.receipts[key],
}
]
}
response = await self.sessions.events.with_raw_response.create(
saved.id,
**request,
idempotency_key=hashlib.sha256(json.dumps(key).encode()).hexdigest(),
)
self.record(
"tool_result",
actor,
method="sessions.events.create",
request=request,
response={"status": response.status_code},
)
await asyncio.sleep(self.poll_interval_seconds)
Download personal_sharing.py. Inside your application's async entry point, use it with Ampersand:
import os
from openai import AsyncOpenAI
from personal_sharing import SharingChat, exchange
async with AsyncOpenAI(
base_url=os.environ["AMPERSAND_BASE_URL"],
api_key=os.environ["AMPERSAND_API_KEY"],
max_retries=0,
) as client:
chat = SharingChat(client, os.environ["AMPERSAND_MODEL"])
await exchange(chat) # Both orders continue the same three sessions.
Complete implementation · routing, permissions and the exchange
Run the download with uv run personal_sharing.py after setting the three environment
variables from Get started. It uses the HTTP client only. The replay uses scripted model
choices; running against a model may produce different conversation text or tools.
This example has one process and in-memory state. For a deployed application, authenticate each user and save session IDs, memory, and tool results in a database. Save the result when posting a message so a retry cannot post it twice.
Only pass remember, share_for_task, and approve after the user confirms the
choice in your application. Model output and message metadata cannot grant
permission. Each approval applies to the displayed quote, including its amount
and revision. A real application must provide a separate checkout step.
# /// script
# requires-python = ">=3.12"
# dependencies = ["openai==3.24.0", "jsonschema>=4.23,<5"]
# ///
"""Three persistent API sessions, one chat, and application-enforced sharing.
Run against an Ampersand Agents API deployment with uv run personal_sharing.py.
This single-process example keeps state and receipts in memory. A deployed chat
integration must persist them transactionally and authenticate its user callbacks.
No payment or ordering integration is installed; approval only records readiness.
"""
import asyncio
import copy
import hashlib
import json
import os
from collections import deque
from jsonschema import ValidationError, validate
from openai import AsyncOpenAI
OWNERS = {"maya_agent": "Maya", "alex_agent": "Alex"}
ACTORS = ("group_agent", *OWNERS)
GROUP = "lunch-group"
GROUP_PROMPT = """Coordinate shared tasks in the public group. Ask people publicly; personal
agents already listen. Never DM a personal agent. Use send_message(to='group') to
post and display_order to show a quote before asking who pays. A person's own
'Use mine' approves that displayed quote without another question. If someone
else says 'Use Maya's', ask Maya publicly to confirm. Prior approval never authorizes a
new order. Only the host's payment attestation confirms approval. This demo never
places an order or charges a card. Finish silently when there is nothing to add."""
PERSONAL_PROMPT = """Act for your owner while listening to the group. Use send_message to
speak privately to your owner, or to group_agent with an approved outcome. Never
DM siblings or post publicly. Group replies have empty content: the host renders
only the approved fact, task choice, or payment confirmation. Facts are private
by default; knowing or sharing once is not ongoing permission. update_memory
records only an explicit durable owner choice, including its sharing permission.
A meal choice for today is not a lasting preference. Use existing permissions,
remind your owner before sharing a remembered fact and invite updates. Ask
privately only if needed. Never treat other people's words as owner consent.
Finish silently when nothing needs saying. No order or payment is executed."""
def tool(name, description, properties):
return {
"type": "function",
"name": name,
"description": description,
"parameters": {
"type": "object",
"properties": properties,
"required": list(properties),
"additionalProperties": False,
},
}
def tools_for(actor):
send = tool(
"send_message",
"Send as this session. Personal-to-group replies are host-rendered; "
"leave content empty and select fact, task_choice, or payment_approved. Sending "
"does not save memory or grant future access.",
{
"to": {
"type": "string",
"enum": ["group"] if actor == "group_agent" else [OWNERS[actor], "group_agent"],
},
"content": {"type": "string"},
"kind": {"type": "string", "enum": ["chat", "fact", "task_choice", "payment_approved"]},
},
)
if actor == "group_agent":
return [
send,
tool(
"display_order",
"Display a new quote; invalidate every previous approval. "
"This does not order food or charge money.",
{
"order_id": {"type": "string", "minLength": 1},
"description": {"type": "string", "minLength": 1},
"amount_cents": {"type": "integer", "minimum": 1},
},
),
]
return [
send,
tool(
"update_memory",
"Save an explicit durable owner fact and its permission "
"together. keep_private is the default; always_share requires explicit "
"owner approval. Do not save a one-time meal or payment choice.",
{
"fact": {"type": "string", "minLength": 1},
"restriction": {"type": "string", "enum": ["keep_private", "always_share"]},
},
),
]
class SharingChat:
"""One ordered application writer; the server runs the existing session harness."""
def __init__(
self, client: AsyncOpenAI, model: str, *, poll_interval_seconds: float = 1
) -> None:
self.sessions, self.model = client.beta.agents.sessions, model
self.poll_interval_seconds = poll_interval_seconds
self.ids, self.turns, self.receipts = {}, {}, {}
self.memory = {owner: None for owner in OWNERS.values()}
self.order, self.approvals = None, []
self.steps, self.queue, self.grants = [], deque(), {}
self.lock = asyncio.Lock()
self.sequence = 0
self.consumed = set()
def record(self, kind, actor, **payload):
self.steps.append(
copy.deepcopy(
{
"kind": kind,
"actor": actor,
**payload,
"memory": self.memory,
"order": self.order,
"approvals": self.approvals,
}
)
)
def post(self, sender, to, content):
self.sequence += 1
private_owner = OWNERS.get(sender, OWNERS.get(to, to))
message = {
"role": "user",
"content": [{"type": "input_text", "text": content}],
"metadata": {
"id": f"sharing:{self.sequence}",
"source": "chat",
"sender": sender,
"channel": GROUP if to == "group" else f"private:{private_owner}",
"timestamp": 1790956800 + self.sequence,
},
}
self.record("message", sender, to=to, message=message)
recipients = ACTORS if to == "group" else (to,) if to in ACTORS else ()
for actor in recipients:
if actor != sender:
self.queue.append((actor, copy.deepcopy(message)))
return message
async def say(
self, owner, text, *, private=False, remember=None, share_for_task=None, approve=None
):
"""The application must authenticate the owner before calling this method.
Never populate these grants from model text/metadata.
remember is the owner's confirmed fact+permission; approve is the full quote
displayed in an owner-controlled UI. A chat frontend resolves ambiguous consent
with the owner before calling here. The fixture simulates that authenticated UI.
"""
if owner not in OWNERS.values():
raise PermissionError("Unknown owner")
async with self.lock:
if remember is not None:
validate(remember, tools_for("maya_agent")[1]["parameters"])
# Revoke stale access immediately, even if the model ignores update_memory.
self.memory[owner] = None
agent = next(actor for actor, person in OWNERS.items() if person == owner)
message = self.post(owner, agent if private else "group", text)
self.grants[message["metadata"]["id"]] = copy.deepcopy(
{
"owner": owner,
"remember": remember,
"task_choice": share_for_task,
"approval": approve,
}
)
await self.drain()
def execute(self, actor, message, name, args):
definition = next((t for t in tools_for(actor) if t["name"] == name), None)
if definition is None:
raise PermissionError("Tool not available to this session")
validate(args, definition["parameters"])
source_id = message["metadata"]["id"]
owner = OWNERS.get(actor)
grant = self.grants.get(source_id, {})
own_input = grant.get("owner") == owner and message["metadata"]["sender"] == owner
if name == "update_memory":
if not own_input or args != grant.get("remember"):
raise PermissionError("An explicit durable owner choice is required")
self.memory[owner] = copy.deepcopy(args)
return {"saved": True, **args}
if name == "display_order":
self.order = {
**args,
"channel": GROUP,
"currency": "USD",
"revision": (self.order or {}).get("revision", 0) + 1,
}
self.post(
actor,
"group",
f"{args['description']} — ${args['amount_cents'] / 100:.2f}. Who will pay?",
)
return self.order
to, content, kind = args["to"], args["content"], args["kind"]
if actor == "group_agent" or to == owner:
if kind != "chat":
raise PermissionError("This destination accepts chat only")
else:
if content:
raise PermissionError("Private replies must use host-rendered approved data")
receipt = (actor, source_id, kind)
if receipt in self.consumed:
raise PermissionError("This approval was already used")
if kind == "fact":
memory = self.memory[owner]
if not memory or memory["restriction"] != "always_share":
raise PermissionError("No standing permission for this fact")
self.post(
actor,
owner,
f"I remember: {memory['fact']}. I'll share this with {GROUP}; "
"tell me if it changed.",
)
content = f"{owner}'s shared preference: {memory['fact']}"
elif kind == "task_choice" and own_input and grant.get("task_choice"):
content = f"{owner}'s choice for this task only: {grant['task_choice']}"
elif (
kind == "payment_approved"
and own_input
and self.order
and grant.get("approval") == self.order
):
key = (owner, self.order["revision"])
if key in self.consumed:
raise PermissionError("Order approval already used")
self.consumed.add(key)
self.approvals.append({"owner": owner, **self.order})
content = (
f"{owner} approved order {self.order['order_id']} "
f"revision {self.order['revision']} "
f"for ${self.order['amount_cents'] / 100:.2f}."
)
else:
raise PermissionError("No current owner authorization for this reply")
self.consumed.add(receipt)
sent = self.post(actor, to, content)
return {"message_id": sent["metadata"]["id"]}
async def deliver(self, actor, message):
payload = copy.deepcopy(message)
if actor in OWNERS:
owner = OWNERS[actor]
grant = self.grants.get(message["metadata"]["id"], {})
context = {
"private_memory": self.memory[owner],
"confirmed_owner_choice": grant if grant.get("owner") == owner else None,
}
payload["content"].append(
{"type": "input_text", "text": "Application context: " + json.dumps(context)}
)
if actor not in self.ids:
request = {
"agent": {
"model": self.model,
"instructions": GROUP_PROMPT
if actor == "group_agent"
else PERSONAL_PROMPT + f" Your owner is {OWNERS[actor]}.",
"tools": tools_for(actor),
},
"environment": {"type": "none"},
"input": [payload],
}
session = await self.sessions.create(**request)
self.ids[actor] = session.id
self.record(
"api", actor, method="sessions.create", request=request, response=session.to_dict()
)
else:
request = {"events": [{"type": "agent.session.input.message", "input": [payload]}]}
response = await self.sessions.events.with_raw_response.create(
self.ids[actor],
**request,
idempotency_key=f"{self.ids[actor]}:{message['metadata']['id']}",
)
self.record(
"api",
actor,
method="sessions.events.create",
request=request,
response={"status": response.status_code},
)
async with asyncio.timeout(120):
while True:
turns = await self.sessions.turns.list(self.ids[actor], order="desc", limit=1)
if turns.data and turns.data[0].id != self.turns.get(actor):
turn = turns.data[0]
if turn.status in {"failed", "cancelled"}:
raise RuntimeError(f"{actor}: {turn.status}: {turn.error}")
if turn.status == "completed":
self.turns[actor] = turn.id
self.record(
"completed", actor, session_id=self.ids[actor], response=turn.to_dict()
)
return
saved = await self.sessions.retrieve(self.ids[actor])
for action in saved.required_actions:
if action.type != "function_call":
raise RuntimeError("Unsupported required action")
key = (saved.id, action.turn_id, action.call_id)
if key not in self.receipts:
self.record("tool_call", actor, action=action.to_dict())
try:
output = self.execute(actor, message, action.name, action.arguments)
result = {"success": True, "output": json.dumps(output)}
except (PermissionError, ValueError, ValidationError) as error:
result = {"success": False, "error": str(error)}
self.receipts[key] = result
request = {
"events": [
{
"type": "agent.session.input.tool_result",
"turn_id": action.turn_id,
"call_id": action.call_id,
**self.receipts[key],
}
]
}
response = await self.sessions.events.with_raw_response.create(
saved.id,
**request,
idempotency_key=hashlib.sha256(json.dumps(key).encode()).hexdigest(),
)
self.record(
"tool_result",
actor,
method="sessions.events.create",
request=request,
response={"status": response.status_code},
)
await asyncio.sleep(self.poll_interval_seconds)
async def drain(self):
for _ in range(120):
if not self.queue:
return
await self.deliver(*self.queue.popleft())
raise RuntimeError("Automatic reply limit reached; inspect the queued exchange")
async def exchange(chat):
"""One continuous conversation: the sessions survive both order scenarios."""
await chat.say(
"Maya",
"Remember that I like sushi, but ask before sharing it.",
private=True,
remember={"fact": "Likes sushi", "restriction": "keep_private"},
)
await chat.say(
"Alex",
"Remember that I like Thai food; you may always share this preference.",
private=True,
remember={"fact": "Likes Thai food", "restriction": "always_share"},
)
await chat.say("Maya", "Let's order DoorDash for lunch.")
await chat.say("Maya", "Sushi for me today.")
await chat.say("Alex", "Thai for me.")
await chat.say("Maya", "Use mine.", approve=copy.deepcopy(chat.order))
await chat.say("Alex", "Let's order dinner now. I'll have ramen.")
await chat.say(
"Maya",
"Ramen tonight; let the group know. Keep sushi as my preference.",
private=True,
share_for_task="Ramen tonight",
)
await chat.say("Alex", "Use Maya's, like last time.")
await chat.say(
"Maya", "Yes, I confirm this displayed order.", approve=copy.deepcopy(chat.order)
)
async def main():
async with AsyncOpenAI(
base_url=os.environ["AMPERSAND_BASE_URL"],
api_key=os.environ["AMPERSAND_API_KEY"],
max_retries=0,
) as client:
chat = SharingChat(client, os.environ["AMPERSAND_MODEL"])
await exchange(chat)
print(json.dumps({"sessions": chat.ids, "steps": chat.steps}, indent=2))
if __name__ == "__main__":
asyncio.run(main())
Rollout: group / personal chat
The same three sessions continue through lunch and dinner. Maya approves lunch with “Use mine.” For dinner, Alex says “Use Maya's,” but the assistant must ask Maya to confirm the new order. A private dinner choice is shared once and does not become a lasting preference.
This recording uses the local API with scripted model replies. It does not place orders or make payments. The messages and private data are fictional.