Cominty Python SDK
Official async Python client for the Cominty managed agent chat API.
Start a conversation with an agent, stream its progress live, and manage threads with a small, fully-typed surface that's the same on Python 3.9 through 3.13.
import asyncio
from cominty_sdk import AsyncCominty
async def main() -> None:
async with AsyncCominty() as client: # reads COMINTY_API_KEY + COMINTY_USER_ID
run = await client.chat.start(
agent_id="__cominty_agents::agent.chat", message="What is Cominty?"
)
print(await run.text())
asyncio.run(main())
- Async-first, built on
httpx. - Fully typed: ships
py.typed; strict-checked with pyright. Pydantic models everywhere. - One handle for streaming and awaiting: iterate a run for live progress
events, or just
await run.text()for the final answer. - Fail-fast validation: bad parameters raise locally, before any request.
- Typed errors: every failure is a
ComintyErrorsubclass.
Requirements
- Python 3.9+
- A Cominty API key and your user id (see Authentication)
Installation
pip install cominty-sdk
# or
uv add cominty-sdk
Authentication
You need two things, both from platform.cominty.ai:
- API key → platform.cominty.ai/api-keys (shown once: copy it).
- Your user id → avatar (top right) → Profile. It looks like
user_31HPTBuBvX20xlQNAbvxjOxPbKB.
The user id identifies the end user every request acts on behalf of. It's set
once on the client (or via COMINTY_USER_ID) and applied to every call.
The simplest setup is environment variables:
export COMINTY_API_KEY="<your API key>"
export COMINTY_USER_ID="user_..."
async with AsyncCominty() as client: # picks both up from the environment
...
…or pass them explicitly (explicit arguments win over the environment):
client = AsyncCominty(api_token="<your API key>", user_id="user_...")
A malformed user_id is rejected at construction, not as a server error later.
Picking an agent
Every chat call takes an agent_id. Browse your agents and copy an id at
platform.cominty.ai/agents: they look
like __cominty_agents::agent.chat.
Quick start
Every conversation starts with chat.start, which returns a run: a handle
to the assistant's in-progress reply. From there, pick the style you need.
Just get the answer
run = await client.chat.start(agent_id=AGENT_ID, message="Give me one fun fact.")
print(await run.text()) # blocks until the agent finishes
await run.result() gives the full Message (status, files, structured output,
questions). text() is shorthand for result().content.
Stream progress events
Iterating a run yields progress events only (tool calls, LLM steps, the result event) as they happen. The finished reply is captured for you.
from cominty_sdk import events
run = await client.chat.start(agent_id=AGENT_ID, message="Research X and summarize.")
async for event in run:
if isinstance(event, events.ToolCall):
print(f"tool {event.data.name} -> {event.status}")
elif isinstance(event, events.LlmStep):
print(f"llm {event.data.description}")
elif isinstance(event, events.Result):
print(f"cost {event.data.cost.total}")
print("FINAL:", await run.text()) # available after the stream drains
A run's stream is single-use: iterate it or await its result: the result is cached, so calling
text()/result()after iterating is free.
Continue the conversation
chat.send(thread_id, ...) is the mirror of start for an existing thread:
same arguments, same streamable run. The agent keeps the thread's context.
first = await client.chat.start(agent_id=AGENT_ID, message="Pick a language.")
await first.text()
second = await client.chat.send(
first.thread.id,
agent_id=AGENT_ID,
message="Now show hello-world in it.",
)
print(await second.text())
Answer the agent's questions
When an agent needs more input, it ends its turn with clarifying questions
(a prompt plus suggested options) instead of a final answer. Read them, then
answer with a normal follow-up:
run = await client.chat.start(agent_id=AGENT_ID, message="Book me a room.")
await run.text()
for q in await run.questions():
print(q.prompt, q.options)
# answer = the chosen option (or free text)
reply = await client.chat.send(run.thread.id, agent_id=AGENT_ID, message="Tomorrow 10am")
print(await reply.text())
Cap the agent's tool rounds
max_steps limits how many tool rounds (web search, file write, ...) the agent
may run for one message. Reaching it is not an error: the agent stops using
tools, recaps, and asks whether to continue, and the message ends with
status="success". Continue with a normal follow-up.
run = await client.chat.start(
agent_id=AGENT_ID, message="Research X, then write it up.", max_steps=5
)
await run.text()
# The cap is per message: pass it again to keep it. Leaving it out runs with
# the server default (60 today).
reply = await client.chat.send(
run.thread.id, agent_id=AGENT_ID, message="Yes, continue.", max_steps=10
)
print(await reply.text())
Leave max_steps unset (or pass SERVER_DEFAULT) to omit it from the request.
There is no "unlimited": None and integers below 1 raise InvalidParams. No
field tells you the cap was hit, and the reply wording is model-written, so don't
parse it. See examples/10_max_steps.py.
Stop a message
chat.cancel(message_id) stops an assistant message that is still being generated
and returns it with status set to cancelled. It takes effect immediately, and
what the agent produced so far stays in events.
run = await client.chat.start(agent_id=AGENT_ID, message="Write a very long report.")
async with run: # closes the event stream when we leave the block
async for event in run:
if user_clicked_stop():
message = await client.chat.cancel(run.message_id)
print(message.status) # MessageStatus.cancelled
break
Cancelling is not idempotent: a message that already finished, failed, or was
already cancelled raises APIError (status 400, "Message is not live"), so a
"stop" button should treat that error as "too late". An unknown message raises
NotFoundError. See examples/12_cancel_message.py.
Export a message as a document
chat.export(message_id, format=...) renders a finished message to a file and
returns its bytes. format is "pdf" or "docx" (any other value raises
InvalidParams before a request is sent).
from pathlib import Path
run = await client.chat.start(agent_id=AGENT_ID, message="Write a short product brief.")
reply = await run.result()
pdf = await client.chat.export(reply.id, format="pdf")
Path("brief.pdf").write_bytes(pdf)
The server sends no file name, so pick one when you write the bytes. A message
without content (for instance one still running) raises APIError (status 400,
"Message has no content"). See
examples/13_export_message.py.
Give the agent files
chat.upload_file registers a file, and its id goes in file_ids (5 files per message at
most). Pass raw bytes (then filename is required) or a pathlib.Path (the name and the
mimetype are inferred from it, and both can still be overridden).
from pathlib import Path
report = await client.chat.upload_file(Path("q3-report.pdf"))
data = await client.chat.upload_file(b"a,b\n1,2\n", filename="data.csv")
run = await client.chat.start(
agent_id=AGENT_ID, message="Compare these two files.", file_ids=[report.id, data.id]
)
reply = await run.result()
# Download any conversation file by id, including the ones the agent produced.
# Keep only the last path segment: file names are not under your control.
for file in reply.files:
Path(Path(file.name).name).write_bytes(await client.chat.download_file(file.id))
A plain str is rejected (InvalidParams): it would be ambiguous between a path and text, so
wrap a path in Path(...). The bytes go straight to the storage backend through a presigned
URL: your API key is only ever sent to the Cominty API. A file is at most 500 MiB: a bigger one
raises InvalidParams before anything is sent. The server also caps the number of uploads per
day per organization, and another deployment may set a lower size limit; such a refusal raises
APIError with the storage reason (for instance EntityTooLarge). The API has no way to delete or list uploaded files.
Files are held in memory, in both directions. Error messages never show a presigned URL's
signature, but httpx itself logs full request URLs at INFO: keep its logger at WARNING.
See examples/14_upload_files.py.
Manage threads
client.threads is scoped to the client's user_id automatically.
# List & search the user's conversations (summaries: no messages)
for t in await client.threads.list(limit=20):
print(t.created_at, t.name, t.id)
await client.threads.list(terms=["invoice"]) # free-text search
await client.threads.list(limit=10, page=1) # paginate (zero-based)
# Load one thread's full history
thread = await client.threads.get(thread_id)
print(len(thread.messages))
# Partial update: only the fields you pass change (returns a ThreadSummary)
await client.threads.update(thread_id, name="Renamed", starred=True)
# Archive (soft-delete)
await client.threads.archive(thread_id)
Memory files
client.memory stores files an agent can read back later, scoped to a
namespace: a bag name you choose. There's no call to create a namespace;
the first create() against a new name brings that bag into existence.
# Create a file. namespace is required on every call below.
file = await client.memory.create(
path="tone.md", namespace="support-bot", purpose="writing style", content="Keep it casual."
)
# List files in one bag (summaries: no content). Omit namespace to list
# every bag visible to this API key.
for f in await client.memory.list(namespace="support-bot"):
print(f.path, f.purpose, f.version)
# List every namespace that already has at least one file.
await client.memory.list_namespaces()
# Read one file's content
file = await client.memory.get("tone.md", namespace="support-bot")
# Partial update: only the fields you pass change. `version` guards against
# overwriting a concurrent change: pass back the value from your last read,
# and a stale one raises ConflictError (409).
file = await client.memory.update(
"tone.md", namespace="support-bot", version=file.version, content="Keep it upbeat."
)
# Delete
await client.memory.delete("tone.md", namespace="support-bot")
version is an opaque token: never parse or compare it, just round-trip
whatever the API last gave you.
There's currently no way to clear content or purpose once set: the API
ignores an explicit null (leaves the existing value untouched), so
memory.update(..., content=None) raises InvalidParams locally rather than
sending a request that looks like it succeeded but did nothing.
A few other things worth knowing:
namespacemay be at most 128 characters. It's never trimmed: an empty or whitespace-only value is sent to the API exactly as given.pathmay have at most one folder segment:"preferences/tone.md"is fine,"a/b/tone.md"isn't (raisesInvalidParamslocally).contentmay be an empty string; there's no minimum length.memory.delete()is not idempotent: deleting an already-deleted path raisesNotFoundError, not a repeated success.- A thread is attached to a bag with
MemoryCapabilityinchat.start(thread_capabilities=...); the bag is frozen once the thread starts (see Capabilities).
Capabilities
A capability is something the agent may use: web, indexed_documents, mcp, skills,
image_generation, video_generation, music_generation, plus memory. Each one is an
explicit object, and the keyword name tells you the scope:
from cominty_sdk import (
ALL,
AgentCapabilities,
ImageGenerationCapability,
ImageGenerationScope,
IndexedDocumentsCapability,
IndexedDocumentsScope,
McpCapability,
McpScope,
MemoryCapability,
MessageScope,
SkillsCapability,
WebCapability,
WebScope,
)
run = await client.chat.start(
agent_id=AGENT_ID,
message="Summarize every board deck",
thread_capabilities=AgentCapabilities( # frozen for the thread, start only
web=WebCapability(activation="always"),
# document_ids omitted: defaults to ALL
indexed_documents=IndexedDocumentsCapability(activation="always", source_ids=[101]),
# explicit ALL, same as omitting connections
mcp=McpCapability(activation="on_request", connections=ALL),
skills=SkillsCapability(activation="always"),
image_generation=ImageGenerationCapability(activation="on_request"),
memory=MemoryCapability(activation="always", namespace="support-bot"),
),
message_scope=MessageScope( # this message only
image_generation=ImageGenerationScope(enabled=True)
),
)
await client.chat.send(
run.thread.id,
agent_id=AGENT_ID,
message="Only deck 2024",
message_scope=MessageScope(
indexed_documents=IndexedDocumentsScope(enabled=True, source_ids=[101]),
mcp=McpScope(enabled=True, connections=["linear"]),
web=WebScope(enabled=False),
),
)
- There are no shortcuts: no
web=True, noweb="on_request". Thread capabilities are<Name>Capability(activation=...)objects, message scopes are<Name>Scope(enabled=...)objects, and the activation is always required. - Activation is
"always"(on by default),"on_request"(off until a message enables it) or"never"(not installed).SkillsCapabilityandMemoryCapabilityhave no"on_request". A capability you do not pass keeps the agent's value. - Filters (
source_ids,document_ids,connections,include) default toALL(the string"*", no restriction), so passingALLonly makes the intent explicit. A capability you send replaces the stored one, so an omitted filter resets toALL. Otherwise pass a non-empty list:None,[]and a"*"inside a list raiseInvalidParams. "never"andenabled=Falsesend nothing else: passing a filter with them raisesInvalidParams.MemoryCapability(activation="always", namespace=...)attaches the thread to a memory bag,MemoryCapability(activation="never")runs it without memory. A message has no memory scope. Without amemoryentry, the thread uses the agent's namespace, or no memory if the agent has none. The bag is frozen for the thread: a follow-up cannot change it.MessageScopehas noskillsormemory.- A message can only narrow what the thread allows. The server answers 422 (
APIError) when it widens an allowlist or targets a capability the thread does not have.
Examples
Runnable scripts for each scenario live in examples/:
| Script | Shows |
|---|---|
01_stream_events.py |
Stream progress events live |
02_await_result.py |
Fire and await the final answer |
03_follow_up.py |
Continue in the same thread |
04_answer_questions.py |
Read & answer agent questions |
05_list_threads.py |
List and search threads |
06_manage_thread.py |
Get, rename/star, archive |
07_custom_agent.py |
Call a custom managed agent (your own model + instructions) |
08_mcp_linear.py |
Custom agent pulls live context from an MCP server (Linear) |
10_max_steps.py |
Cap tool rounds per message, then continue past the cap |
11_capabilities.py |
Thread and message capabilities |
12_cancel_message.py |
Stop a message that is still being generated |
13_export_message.py |
Export a finished message as a PDF or DOCX file |
14_upload_files.py |
Upload files, attach them to a message, download one back |
They render colored, aligned output with rich,
which ships in the dev extras:
uv sync --all-extras --dev # installs rich (or: pip install rich)
export COMINTY_API_KEY=... COMINTY_USER_ID=user_...
python examples/01_stream_events.py
Message parameters
Both chat.start and chat.send accept:
| Argument | Type | Notes |
|---|---|---|
agent_id |
str |
Required. The agent to run. |
message |
str |
Required. The user's message (max 30,000 chars). |
name |
str |
start only: names the new thread. |
file_ids |
list[str] |
Ids of files uploaded with chat.upload_file() (max 5). |
thread_capabilities |
AgentCapabilities |
start only: capability override, frozen for the thread. Includes the memory bag (MemoryCapability). |
message_scope |
MessageScope |
Turn capabilities on/off or narrow them for this message (sent as message.scope). |
max_steps |
int |
Cap on tool rounds for this message (integer >= 1). Per message, not per thread. Unset (or SERVER_DEFAULT) means the server default, 60 today. |
Invalid values raise InvalidParams before any request is sent.
Configuration
| Argument | Env var | Default |
|---|---|---|
api_token |
COMINTY_API_KEY |
(required) |
user_id |
COMINTY_USER_ID |
(required) |
base_url |
COMINTY_BASE_URL |
https://ds.cominty.com |
timeout |
none | 60 (seconds) |
Resolution order for each option: explicit argument → environment variable →
default. The SDK does not auto-load .env; export the vars or load the
file yourself (see .env.example).
Error handling
Every error is a subclass of ComintyError:
from cominty_sdk import (
ComintyError, # base: catch-all
APIError, # any 4xx/5xx; carries .status_code and a typed .error body
AuthError, # 401
PermissionError, # 403
NotFoundError, # 404
ConflictError, # 409
RateLimitError, # 429: exposes .reset_at
ServerError, # 5xx
APIConnectionError, # network failure / timeout, no response
StreamInterrupted, # server shut down mid-stream: carries the .partial Message
InvalidParams, # client-side validation failed: .errors lists each problem
SDKError, # unexpected SDK-internal condition
)
try:
run = await client.chat.start(agent_id=AGENT_ID, message="hi")
print(await run.text())
except RateLimitError as e:
print(f"slow down: retry after {e.reset_at}")
except APIError as e:
print(f"API error {e.status_code}: {e.error}")
Development
uv sync --all-extras --dev
uv run pytest # tests
uv run ruff check . # lint
uv run pyright # type-check (strict)
Coverage (statements + branches) is measured with pytest-cov; CI fails under
100%:
uv run pytest --cov --cov-report=term-missing
Integration tests are opt-in (they hit the real API):
COMINTY_API_KEY=... COMINTY_USER_ID=... uv run pytest -m integration
See AGENTS.md for coding conventions (typing, versioning, models).
Developer tasks
uv run invoke --list # show all tasks, including the code.* namespace
uv run invoke code.format # ruff format, then ruff check --fix
uv run invoke code.check # ruff check, ruff format --check, pyright
uv run invoke code.test # pytest --cov, same --cov-fail-under=100 floor as CI
uv run invoke code.all # format, then check, then test: run before opening a PR
Releasing
Publishing to PyPI uses Trusted Publishing (OIDC): no tokens stored in
GitHub, triggered by publishing a GitHub Release
(.github/workflows/release.yml). The published version comes from
pyproject.toml.
uv run invoke release modifies repository files and CREATES A GIT COMMIT
AND TAG. It bumps pyproject.toml, regenerates uv.lock, runs the lint /
type-check / test / build gate, then commits and tags: it never pushes and
never creates the GitHub Release itself. It asks for confirmation before
writing any file, and again before committing and tagging; pass --yes to
skip both prompts.
# 1. bump, validate, commit, and tag locally: pick exactly one
uv run invoke release --patch # X.Y.Z -> X.Y.(Z+1)
uv run invoke release --minor # X.Y.Z -> X.(Y+1).0
uv run invoke release --major # X.Y.Z -> (X+1).0.0
uv run invoke release --version X.Y.Z # set an explicit version
# 2. push the commit and the tag it just created
git push origin HEAD --follow-tags
# 3. create the release: this triggers the publish workflow
gh release create vX.Y.Z --title vX.Y.Z --generate-notes
# pre-release rehearsal (skips `invoke release`): tag and push manually,
# then gh release create vX.Y.Zrc1 --prerelease --generate-notes
A local rehearsal to TestPyPI is available via uv run invoke publish-test.
License
MIT
Metadata
Release files for cominty-sdk 1.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| cominty_sdk-1.1.0.tar.gz | 32.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| cominty_sdk-1.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 73.1 kB
Release files / cominty_sdk-1.1.0.tar.gz
| Download URL | cominty_sdk-1.1.0.tar.gz |
|---|---|
| Size | 32.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ba14961bede0f8af99dcdcfc82d26e349ce2552580bb2b9ffc296ae591b0caca
|
|
BLAKE2b-256 checksum How to use checksums |
6bccf1913617ca0e5b8d1dfeb55f66b43aa49c959f177112a4a70001a0f8d1d5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 16, 2026.
Transparency logRelease files / cominty_sdk-1.1.0-py3-none-any.whl
| Download URL | cominty_sdk-1.1.0-py3-none-any.whl |
|---|---|
| Size | 40.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
8c00ef01b11304a59ae84df9704a72109a5ff711d8d209e806466e3406eb49eb
|
|
BLAKE2b-256 checksum How to use checksums |
aa0cd211a67a918e9e39f8a2531c96215a2923c3c21163bbb03152aa88add417
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 16, 2026.
Transparency log