Skip to main content

signalcore

SignalCore's server SDK for Python: your backend makes organizations for your customers, sets up their agents, talks to them and hears what happens, through SignalCore's public API (https://signalcore.ai/v1). Every operation of the API is a typed method; the SDK holds no logic of its own.

pip install "signalcore>=5,<6"     # uv add, poetry add alike

Python 3.10 or later. Version 5 is for SignalCore 2.0's API as it is now: everything the owner does in the app (keys, contacts, an agent's access, dashboards, the inbox's approvals), and a request of the wrong shape refused as every error is (ApiError with code == "invalid"). 4.x answered those as code == "error"; 2.x was the API with squads, 0.x the API it replaced. Full docs, in Spanish: signalcore.ai/docs.

Keys

A secret key (sk_…) is made by the account's owner in the app, in Tu plataforma → Claves (the app shows Tu plataforma to an account of kind "Lo integro en mi producto"; any account can switch in Ajustes → General → Tipo de cuenta). It is shown once: keep it in your server's environment, never in a page. Each key has scopes:

Scope Lets the key
read read everything (every GET)
write change the organization: agents, their folder, files, tables, the vault, automations, triggers, channels, apps, contacts, who answers a conversation, the inbox, your tools
talk start conversations, speak in them, call an agent, upload a conversation's files, write as the business on a channel, mint person tokens
admin make and delete your customers' organizations, rename your own, deploy and update templates, make and remove webhooks, make, list and revoke keys

A key without the scope gets 403 forbidden. Revoking a key ends every token it minted.

Once you have one with admin, your code makes the rest, never with more scopes than its own (403 otherwise); a publishable key talks, so it takes read and talk:

made = client.keys.create(kind="secret", name="Reader", scopes=["read"])
made["key"]  # sk_…: shown this once; listed after only by its `prefix`
client.keys.list()  # newest first, the revoked ones too
client.keys.revoke(made["id"])
SignalCore(org="clinica-norte").keys.create(kind="secret", name="Theirs", scopes=["read"])  # a customer's own
from signalcore import SignalCore

client = SignalCore()  # reads SIGNALCORE_API_KEY; or SignalCore("sk_…")

Keyword options: org (act on a customer's organization, below), base_url (SIGNALCORE_BASE_URL, or https://signalcore.ai), timeout in seconds (90), http_client (an httpx.Client of your own). AsyncSignalCore has every method of SignalCore, awaited, and its http_client is an httpx.AsyncClient. Both close with close() or as a context manager.

Quickstart

agent = client.agents.create(
    name="Recepción",
    instructions="You answer the clinic's patients briefly, in Spanish.",
)
conversation = client.conversations.start(agent["id"])

reply = client.conversations.say(conversation["id"], text="¿Abrís el sábado?", wait=True)
print(reply["status"], reply["replies"][0]["text"])

Answers are plain dicts typed with TypedDicts (signalcore.types), so your editor knows every field.

Agents

agents = client.agents.list()  # the builder first (is_builder), then by name
one = client.agents.get("agt_…")

client.agents.update(
    one["id"],
    instructions="Answer in Spanish, in two sentences at most.",
    model="claude-opus-5-5",  # client.catalog.models() lists the ids
    timezone="Europe/Madrid",
)
client.agents.delete(one["id"])  # the builder can't be deleted

agents.create takes name and, optionally, role, description, instructions, model and icon; a name the organization has already is 409 conflict. agents.update changes only what it is passed, and also takes voice (how it talks on calls), packages (what its own code installs), actions and needs_approval, and what the agent may set up itself in a conversation: connects, the apps it connects with connect_app (which kinds or toolkits, whose account, what it gets of one, who gives the yes for its writes), and switches, the automations it turns on or off with switch_automation:

client.agents.update(
    one["id"],
    actions=[*one["actions"], "connect_app", "switch_automation"],
    connects={
        "kinds": ["crm"],
        "whose": "person",
        "gets": "all",
        "writes": "person",
        "return_url": "https://your.app/chat",  # where the person lands once they authorized it
    },
    switches=[{"agent": "Coach", "automation": "weekly_report"}],  # no agent: its own
)

Knowledge

Files every agent of the organization reads, or one agent's own. searchable=True indexes it (PDF, Word, Excel, PowerPoint, text) so the agent searches it when asked; up to 50 MB a file.

from pathlib import Path

client.files.put(
    "tarifas.pdf",
    Path("tarifas.pdf").read_bytes(),
    searchable=True,
    content_type="application/pdf",
)
client.agents.files_put(
    agent["id"],
    "guion.md",
    "# Cómo saludar\n…".encode(),
    searchable=True,
    content_type="text/markdown",
)

hits = client.files.search(query="precio de una limpieza")  # [{path, location, text, …}]
data = client.files.get("tarifas.pdf")  # bytes
client.files.delete("tarifas.pdf")

# Something the agent keeps in mind, as it would learn it itself
client.agents.memories_create(agent["id"], text="Los sábados abrimos de 10:00 a 14:00.")

Tables (client.data), keys the agent uses on the web (client.vault, client.agents.vault_put) and the apps it uses (client.connections) are in the same client.

The agent's own actions

An agent is a folder: agent.yaml, instructions.md and its code. A file in actions/ with one @sc.action function is a tool the model calls while it talks:

PRECIO = '''import sc

PRECIOS = {"limpieza": 45.0, "blanqueamiento": 180.0}


@sc.action
def precio(servicio: str) -> float:
    """El precio de un servicio en euros. Llámala cuando pregunten cuánto cuesta."""
    return PRECIOS[servicio.lower()]
'''

client.agents.folder_put(agent["id"], "actions/precio.py", content=PRECIO)

actions = client.agents.actions_list(agent["id"])  # precio, with source "own"
files = client.agents.folder_list(agent["id"])  # every file of the folder

A file that isn't valid code is refused (422) with the reason. automations/<name>.py is what the agent does on its own; see client.automations (test, switch) and client.runs.

What it may use

What the builder gives and takes, one thing at a time (write): a table and its verbs, an app's tools (connections.actions lists them), a key of the organization's vault. Each answers the agent, its access and actions as they are now.

client.agents.tables_give(agent["id"], "leads", can=["find", "add"])
client.agents.tables_give(agent["id"], "bookings", can=["find"], contact="client")  # theirs only
client.agents.apps_give(agent["id"], "gmail", tools=["Gmail.ListEmails"])
client.agents.keys_give(agent["id"], "stripe")
client.agents.tables_take(agent["id"], "leads")  # apps_take, keys_take alike

A table changes after it was made: fields added or changed by name, dropped, a new title. A field dropped or changed in type loses its values. The contacts table takes your own fields the same way, and contacts.create(fields=…) fills them.

client.data.update("leads", fields=[{"name": "source", "type": "text"}], drop=["vip"], title="Leads 2026")

Dashboards are written as the builder writes them: blocks over the tables its agents use, each query checked first (422 with why, and nothing kept).

board = client.dashboards.create(
    name="sales",
    title="Sales",
    blocks=[{"type": "stat", "label": "Leads", "query": {"table": "leads", "measure": "count"}}],
)
client.dashboards.update(board["id"], title="Sales this month")  # only what is sent
client.dashboards.render(board["id"])  # the figures now

Automations

An automation is code in the agent's folder (automations/<name>.py, see the guide "Automatizaciones en código"): written with folder_put, tried with a dry run, and switched on, since a new one starts paused.

client.agents.folder_put(agent["id"], "automations/reminder.py", content=REMINDER)
client.automations.test(agent["id"], "reminder")  # a dry run, answered when it ends
client.automations.switch(agent["id"], "reminder", on=True)

Your backend runs one now, for one of your users when it works for one, kept as a run by api: one on a schedule, one only started by hand (@sc.manual(inputs={...})) or a webhook, given every input it declares. What its code returns is the run's result (also in run.finished); one written @sc.after("<automation>") starts when that one ends well, given its result. An app's event or a channel's message start the rest: try those with test.

run = client.automations.run(agent["id"], "weekly_summary", contact={"account": "user_42"},
                             inputs={"week": 42}, wait=True)
run["status"], run["result"]  # "done", {"deals": 3, ...}

Conversations

conversation = client.conversations.start(agent["id"])
reply = client.conversations.say(conversation["id"], text="Hola", wait=True)

if reply["status"] == "done":
    print([message["text"] for message in reply["replies"]])
elif reply["status"] == "waiting":
    print(reply["waiting"])  # [{id, action, kind: ask | approve | tool, decides}]
elif reply["status"] == "failed":
    print(reply["error"])  # {code, detail}
else:  # "running": still going after a minute; read it later
    ...

whole = client.conversations.get(conversation["id"])  # with its messages
latest = client.conversations.list(agent["id"])  # newest first

With wait=True the call answers when the agent's turn ends, up to a minute. Without it, it answers at once with running; the reply comes on the live socket or a webhook. While a turn runs, another say is 422 busy; while the turn waits on a question, 422 pending.

When an action waits for the business's yes (decides: "business", also told by the approval.requested webhook), your backend gives it:

client.conversations.decide(conversation["id"], request=reply["waiting"][0]["id"], approve=True)
# Several at once, all kept or none; a yes may change the call's input first (its `schema`)
client.conversations.decide_many(conversation["id"], [
    {"request": "toolu_1", "approve": True, "input": {"amount": 1200}},
    {"request": "toolu_2", "approve": False},
])

Threads, unread, and the agent first

Your backend opens a thread in a user's own chat with its key, no token: titled, with its first words the user's (the agent answers) or the agent's. Their page lists their threads with unread and waiting_on_person, and marks what they read (conversations.read).

thread = client.conversations.start(agent["id"], contact={"account": "user_42"},
                                    title="Demo with Acme", say={"text": "Help me prep", "from": "contact"})
client.conversations.update(thread["id"], title="Acme, Thursday")
client.contacts.conversations(contact_id)  # each with unread, waiting_on_person

The agent writes first (say.from = "agent", or sc.say from an automation) with no limits of SignalCore's: only each channel's rules hold (WhatsApp's and Instagram's 24 h window and templates, and never to who opted out). Such messages carry proactive: true.

Files in a conversation

Upload first, then name the file in say (up to 10 a message):

invoice = client.conversations.files_create(
    conversation["id"],
    Path("factura.pdf").read_bytes(),
    file_name="factura.pdf",
    content_type="application/pdf",
)
client.conversations.say(
    conversation["id"], text="¿Me explicas esta factura?", files=[invoice["id"]], wait=True
)

Live

Every frame of a conversation as it happens:

with client.conversations.live(conversation["id"]) as live:
    live.say("Hola")
    for frame in live:
        if frame["type"] == "text":
            print(frame["delta"], end="", flush=True)
        if frame["type"] == "done":
            break

With AsyncSignalCore, the same with async with, await live.say(…) and async for.

What comes down: working (a turn began), message (a message stored, in message), text (a piece of the reply, in delta), step (an action: running, then done, failed or waiting), ui (a screen the agent drew), request (an action that asks first), tool (a client tool for the person's page), handoff (it went to a person), holder (who answers changed), done (the turn is over), stopped, and error (code, detail). What you send: live.say(text, file_ids), live.stop(), or any frame with live.send({"type": …}). Closing the socket doesn't stop the reply: it is stored.

Your customers' organizations

An agency or a product makes one organization per customer (admin scope). Each comes with its own builder and shares nothing with the others. Name the agents it starts with: your agents that are templates (below).

customer = client.orgs.create(name="Clínica Norte", templates=[agent["id"]])
# {id, name, slug: "clinica-norte", kind: "business", agents: [ … ]}

# Act on it: the same key, with `org` as its id or its slug (SignalCore-Org)
theirs = SignalCore(org=customer["slug"])
theirs.agents.list()  # their own agents
theirs.files.put("carta.pdf", data, searchable=True, content_type="application/pdf")

every = client.orgs.list()  # oldest first
same = client.orgs.get(customer["id"])
client.orgs.delete(customer["id"])  # 409 while it has channels or connected apps left

What each customer used this month, for your bill (from_ and to, a datetime or ISO 8601, for another stretch):

for used in client.usage.managed():
    print(used["org_id"], used["cost"])  # USD

Each of them at a glance, as your home in the app shows them this month (read):

home = client.customers.overview()
for customer in home["customers"]:
    # conversations, cost (USD), and what needs someone: lost channels, failed automations,
    # approvals the business has to give
    print(customer["org"]["name"], customer["conversations"], customer["approvals"])

Templates

One of your agents, given to your customers: each customer's organization gets its own copy, and your changes reach every copy except what that customer changed.

client.templates.deploy(agent["id"])  # version 1, as the agent is now

# Your deploy owns these fields: from now on only it writes them, in every copy
client.templates.deploy(
    agent["id"],
    spec={"instructions": "…", "files": {"actions/precio.py": PRECIO}},
)

client.agents.update(agent["id"], model="claude-opus-5-5")
status = client.templates.update_all(agent["id"])  # a new version, brought to every copy
# status["agents"]: [{org_id, org_name, agent_id, version, overrides}]

theirs.templates.make(agent["id"])  # a copy in an organization that exists (SignalCore-Org)
client.templates.get(agent["id"])  # the template and every copy
client.templates.delete(agent["id"])  # no longer a template; the copies stay

spec takes any of the agent's fields: name, role, description, icon, instructions, model, timezone, voice, packages, actions, asks, access, tools, files.

People in your product

Your backend mints a token for each user (an hour, the agents it names; talk scope), and your page talks with it through @signalcore/ui:

token = client.tokens.create(
    agents=[agent["id"]], external_id=user.id, name=user.name, email=user.email
)
return {"token": token["token"], "expires_at": token["expires_at"]}

For anonymous visitors of a site, connect the agent's web channel; its publishable key (pk_…) goes in the page, and works only from the origins named:

connected = client.channels.web_connect(agent_id=agent["id"], origins=["https://clinicanorte.com"])
key = connected["channel"]["web"]["key"]
# <script src="https://signalcore.ai/v1/widget.js" data-key="pk_…" async></script>

Contacts

The people your agents serve: who writes or calls on a channel, or a user of your product with a token. A conversation names its contact:

conversation = client.conversations.get("cnv_…")
if conversation.get("contact"):
    contact_id = conversation["contact"]["id"]
    contact = client.contacts.get(contact_id)  # {id, name, muted, opted_out, created_at}
    memories = client.contacts.memories(contact_id)  # what the agents keep about them
    tasks = client.contacts.tasks(contact_id)  # what they asked for later
    their_conversations = client.contacts.conversations(contact_id)
    client.contacts.update(contact_id, muted=True)  # the agent stops answering them

Your backend adds the people it knows, by the id your product knows them by (account), a phone, an email or an Instagram id. The first identity given finds them when they are known: the same contact comes back (200, not 201), the other identities added. fields are the contacts table's fields of yours.

marta = client.contacts.create(account="user_42", name="Marta", phone="+34600111222", fields={"plan": "pro"})
client.contacts.list(phone="+34 600 111 222")  # or email, account, instagram, q (words)
client.contacts.list(field=["plan:pro"])  # by a field of yours
for contact in client.contacts.iter():  # every one, page after page (100 at most each)
    print(contact["id"], contact["account"])

Give a contact one of the agent's automations for contacts (for_="contact" in its code), as the agent's schedule_task does: what it takes, and a cron line or a time once (at).

client.tasks.create(agent["id"], contact=marta["id"], automation="digest", inputs={"zone": "north"}, cron="0 8 * * 1")

One person, many identities: add or take away how a contact is known (unique in the organization: another contact's is 409), and merge two contacts that are one person into the one kept (their conversations, memories, tasks, files, keys and apps move; two files, keys or apps of the same name are refused first).

client.contacts.update(marta["id"], add=[{"kind": "phone", "value": "+34600111222"}])
client.contacts.merge(marta["id"], from_="ctc_…")

The inbox

What needs a person: notes, handoffs, failures, and the conversations that wait. Each item names its contact; one that waits lists what it waits on (requests), so your backend gives the business's yes (talk):

for item in client.inbox.list(kind="waiting"):  # agent=, contact= too
    for request in item["requests"]:
        if request["decides"] == "business":
            client.conversations.decide(item["conversation_id"], request=request["id"], approve=True)

What is one person's is kept on their contact, and an agent uses it only where it works for them, never with anyone else: their files (what they sent on any channel lands in received/), their keys of the vault (names only come back) and what the agents know about them.

client.contacts.files_put(contact_id, "poliza.pdf", data, searchable=True, content_type="application/pdf")
client.contacts.files(contact_id)  # [{path, media_type, size, searchable, …}]
client.contacts.files_get(contact_id, "received/foto.jpg")  # bytes
client.contacts.vault_put(contact_id, "portal", value="…", hosts=["portal.example.com"])
client.contacts.memories_create(contact_id, text="Prefiere las tardes.", agent_id=agent["id"])

Your tools

Functions of your own the agent may call. A server tool is a URL of yours that SignalCore POSTs to, signed; a client tool runs in the person's page (@signalcore/ui answers it).

declared = client.tools.put(
    agent["id"],
    "order_status",
    description="The status of an order. Call it when the person asks where their order is.",
    input_schema={
        "type": "object",
        "properties": {"order_id": {"type": "string"}},
        "required": ["order_id"],
    },
    mode="server",
    url="https://api.tuapp.com/signalcore/order-status",
)
# declared["secret"]: the organization's tool secret, only with its first server tool
# (a new one: client.tools.rotate_secret())

Each call is {tool, input, conversation_id, org_id, person}, signed with the tool secret as a webhook is, so the same verify checks it. Answer JSON within 30 seconds:

from signalcore import SignatureError, verify


@app.post("/signalcore/order-status")
async def order_status(request: Request):
    try:
        call = verify(await request.body(), request.headers, TOOL_SECRET)
    except SignatureError:
        raise HTTPException(400)
    return await orders.status(call["input"]["order_id"])

Your API has an OpenAPI document? Import it and pick operations instead: client.tools.import_openapi(agent["id"], url=…), then client.tools.pick_openapi(agent["id"], picked=["listOrders"]).

Webhooks

hook = client.webhooks.create(
    url="https://api.tuapp.com/signalcore",
    events=["message.created", "approval.requested", "run.failed"],
)
# hook["secret"] (whsec_…): shown this once

Events: message.created, conversation.holder_changed, inbox.item_created, run.finished, run.failed, call.ended, channel.status_changed, approval.requested. Each arrives as {id, type, created_at, org_id, data}; check it before trusting it, with the body exactly as it came:

from signalcore import SignatureError, verify


@app.post("/signalcore")
async def hook(request: Request):
    try:
        event = verify(await request.body(), request.headers, WEBHOOK_SECRET)
    except SignatureError:
        raise HTTPException(400)
    if event["type"] == "message.created":
        ...
    return Response(status_code=204)

Answer a 2xx within 10 seconds. Otherwise the delivery is tried again (after 15 s, 30, 60 and 120) and is dead after the fifth try; id is the same each try. To send one again:

for delivery in client.webhooks.deliveries(hook["id"]):
    if delivery["status"] == "dead":
        client.webhooks.retry(hook["id"], delivery["id"])

client.webhooks.verify is the same function as verify.

Usage

from datetime import datetime, timezone

usage = client.usage.get()  # this month so far
# {cost, used: [{kind, unit, quantity, count, cost, unpriced}], messages: [{channel, received, sent}]}
october = client.usage.get(
    from_=datetime(2026, 10, 1, tzinfo=timezone.utc), to=datetime(2026, 11, 1, tzinfo=timezone.utc)
)

Errors, retries and pages

Whatever the API refuses or fails raises one ApiError: status (HTTP), code to decide by and detail, a sentence you can show.

from signalcore import ApiError

try:
    client.agents.get("agt_…")
except ApiError as error:
    if error.code == "not_found":
        ...
status code Means
400 bad_request the request can't be done that way
401 unauthorized no key, or not valid
403 forbidden the key lacks the scope
404 not_found it doesn't exist, or isn't yours
409 conflict it clashes with something that exists
410 gone it was there and isn't any more: expired or used
422 invalid, busy, pending, files, … the request can't be done; a body of the wrong shape is invalid, its detail naming the field first (scopes.0: …)
429 rate_limited more than 600 calls a minute for the key

The SDK doesn't retry by itself. On 429, wait and try again:

import time


def with_retry(call, tries=5):
    for attempt in range(1, tries + 1):
        try:
            return call()
        except ApiError as error:
            if error.status != 429 or attempt == tries:
                raise
            time.sleep(2**attempt)


agents = with_retry(lambda: client.agents.list())

Every write takes idempotency_key=: sent again with the same key within 24 hours, it answers the first answer and isn't done twice.

client.conversations.say(conversation["id"], text="Hola", idempotency_key=f"msg-{message_id}")
client.orgs.create(name="Clínica Norte", idempotency_key=f"customer-{customer_id}")

Three lists come in pages, read whole with iter; every other list answers at once:

for contact in client.contacts.iter(field=["plan:pro"]):
    print(contact["id"])
for run in client.runs.iter(agent=agent["id"], status=["failed"]):
    print(run["id"])
for item in client.activity.iter(kind="conversation"):
    print(item)

Every operation

Each operation of the API reference is a method named from its id, <resource>_<verb>: agents_get is client.agents.get(agent_id), agents_memories_create is client.agents.memories_create(…), templates_update_all is client.templates.update_all(…). signalcore.OPERATIONS maps every id to its method. What is deprecated says so.

  • Path parameters are positional; the body's fields and the query are keyword arguments.
  • A field left out isn't sent; None sends null (signalcore.OMIT leaves one out explicitly).
  • Files go up as bytes with content_type= and come down as bytes.
  • A tool's new name in client.tools.edit is new_name=.

Developing it

Half of the package is generated from the API's contract (v3/api/openapi.json): _resources.py and _types.py, by python v3/clients/generate.py. The rest is by hand: _base.py (requests, errors, pages), _extras.py (the live socket, webhooks). When the contract changes, generate again; CI fails while they differ.

uv sync
uv run pytest      # against the local API (v3-api) at http://localhost:8300, or SIGNALCORE_TEST_API
uv run mypy && uv run ruff check

The tests that need an API sign up an owner of their own there and skip themselves when none answers.

Metadata

Release files for signalcore 5.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for signalcore 5.1.0
File Size Uploaded Provenance
signalcore-5.1.0.tar.gz 122.1 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for signalcore 5.1.0
File Interpreter ABI Platform
signalcore-5.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 181.7 kB

Release files / signalcore-5.1.0.tar.gz

Download URL signalcore-5.1.0.tar.gz
Size 122.1 kB
Tags Source
SHA-256 checksum
How to use checksums
e58736bea138c9b2fc249797416c8dc124d14f6fe0971a4126416572c1480cf8
BLAKE2b-256 checksum
How to use checksums
020d5e250b98f333b649828290658e991e53ae5bf6d0c5cb17fd7ac2b61c646f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

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 22, 2026.

Transparency log

Release files / signalcore-5.1.0-py3-none-any.whl

Download URL signalcore-5.1.0-py3-none-any.whl
Size 59.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
491ba96c1dbc7e7759e548ad163918299b46562d732a2c24a06562793b990356
BLAKE2b-256 checksum
How to use checksums
7f15dcc1250626777f25ed24fe284b071fb520489ec6aaf946fd020dce1963cf
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

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 22, 2026.

Transparency log

Security for signalcore 5.1.0

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.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for signalcore-5.1.0.tar.gz

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 22, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page