gabriel-slack (Python)
Python / asyncio implementation of Gabriel: a typed, AI-friendly real-time SDK for the Slack Web client protocol. It implements the same contract as the TypeScript reference SDK.
Unofficial and not affiliated with Slack. It acts as the signed-in person: use it only where you are authorized to automate, and treat the session cookie like a password.
- Python ≥ 3.11, asyncio, fully typed (
py.typed,mypy --strictclean). - Runtime dependencies:
httpxandwebsockets. - Behavior contract:
spec/behavior.md; every conformance fixture passes and every event validates againstevents.schema.json.
Install from PyPI (the distribution is gabriel-slack, the import package gabriel):
pip install gabriel-slack # or: uv add gabriel-slack
Contents
- Credentials
- Quick start
- Events
- Handler context
- Actions
- AI safety:
scope()andto_llm_context() - Reliability
- Errors
- Low-level core
- Configuration reference
- CLI
- Python conventions
- Development
Credentials
The only required credential is the d cookie (xoxd-…) of a signed-in Slack web session.
The SDK exchanges it for the workspace token (xoxc-…), team ID and user ID, refreshes the
token on invalid_auth (concurrent failures share one refresh), and stops with auth.dead
when the cookie itself expires.
- Load it from the environment or a secret manager:
GABRIEL_COOKIE_D(and optionallyGABRIEL_WORKSPACE). - For local development,
gabriel auth parse-curl --savestores it in~/.config/gabriel/auth.json(mode 0600, shared with the Node CLI);load_session_cookie()reads the environment first, then that file. - Use
allowed_teamsto refuse connecting to any other workspace.
The SDK never logs, emits or returns tokens or cookies; reprs of credential objects are redacted.
Quick start
import asyncio
from gabriel import GabrielClient, MessageCreatedEvent, load_session_cookie
async def main() -> None:
session = load_session_cookie()
client = GabrielClient.from_cookie(
cookie_d=session.cookie_d, workspace=session.workspace, allowed_teams=["T0123456789"]
)
@client.on_message(mentions_me=True)
async def greet(evt: MessageCreatedEvent, ctx) -> None:
if evt.origin != "other_user":
return # skip bots and your own messages typed elsewhere
await ctx.reply(f"Hi {evt.sender.display_name or 'there'} 👋")
@client.on("auth.dead")
def dead(evt, ctx) -> None:
alert(f"Gabriel session expired: {evt.reason}")
client.on_error(lambda err, evt: print(evt and evt.event_id, err))
async with client: # start() … stop(drain=10.0)
await asyncio.Event().wait()
asyncio.run(main())
More in examples/: a streamed reply to mentions, a read-only live smoke test and
raw frames. Run one with GABRIEL_COOKIE_D=… uv run python examples/mention_stream_reply.py.
Events
Subscribe with client.on(type_or_class, handler), client.on_message(matcher, handler),
client.on_thread_reply(handler) — each also works as a decorator — or pull with
async for evt in client.events(). Handlers receive (evt, ctx) and may be sync or async.
Every event is a dataclass with snake_case attributes mapping 1:1 to the schema's camelCase
fields; evt.to_dict() / evt.to_json() produce the schema form. Select events by type string
("message.created") or by class (MessageCreatedEvent).
| Event | Class | When |
|---|---|---|
message.created |
MessageCreatedEvent |
Any new message: channel, thread reply, broadcast, file share, bot. |
message.updated |
MessageUpdatedEvent |
The body actually changed (metadata-only updates are filtered). |
message.deleted |
MessageDeletedEvent |
A message was deleted (deleted_ts). |
message.thread_meta_updated |
MessageThreadMetaUpdatedEvent |
A thread's reply count / latest reply changed. |
message.other |
MessageOtherEvent |
Message subtypes without a dedicated model. |
ai.stream_updated / ai.stream_completed |
AiStreamUpdatedEvent / AiStreamCompletedEvent |
Another AI app streams / finishes a reply. |
presence.changed |
PresenceChangedEvent |
A watched user switched active ⇄ away (is_initial_snapshot for the first value). |
user.status_changed / user.dnd_changed |
UserStatusChangedEvent / UserDndChangedEvent |
A watched user's custom status / DND changed. |
user.subteam_updated |
UserSubteamUpdatedEvent |
A user group's members changed. |
interaction.typing |
TypingEvent |
Someone is typing. |
interaction.reaction_added / _removed |
ReactionAddedEvent / ReactionRemovedEvent |
Reaction on a message (target.thread_root_ts resolved) or file. |
channel.lifecycle |
ChannelLifecycleEvent |
Joined / left / DM opened / renamed / archived. |
channel.marked |
ChannelMarkedEvent |
Your read cursor moved (the SDK's own cursor changes are hidden). |
unread.activity / unread.badge_counts |
UnreadActivityEvent / UnreadBadgeCountsEvent |
Activity item / badge snapshot (deduplicated). |
connection.ready / .reconnecting / .gap |
Connection*Event |
Connection lifecycle and backfill reports. |
auth.refreshed / auth.dead |
AuthRefreshedEvent / AuthDeadEvent |
Token refreshed / session expired (terminal). |
guard.loop_detected / .protocol_drift / .overflow |
LoopDetectedEvent / ProtocolDriftEvent / OverflowEvent |
Circuit breaker / unknown frame / event dropped under load. |
Key fields of MessageCreatedEvent: ts, channel (id, name, is_private, is_im,
is_mpim), sender (id, display_name, handle, is_bot, is_external), origin, text
(raw mrkdwn), markdown (standard Markdown, mentions rendered as @Name), thread
(root_ts, is_reply, broadcast or None), is_thread_reply, thread_ts, mentions,
is_mentioning_me, files, delivery (realtime | backfill), raw.
origin distinguishes this_client (an echo of this SDK instance's own writes, dropped
unless LoopGuardOptions(include_self_messages=True)), self_other_client, other_user and bot.
Handler context
@client.on(MessageCreatedEvent)
async def handle(evt, ctx):
ctx.signal # AbortSignal: aborted on delete, timeout or stop()
await ctx.reply("text") # in the trigger's thread (in_thread=False for top level)
stream = await ctx.reply_stream() # streaming reply in the trigger's thread
await ctx.react("eyes")
answer = await ctx.wait_for_reply(lambda r: r.thread_ts == evt.ts, timeout=60) # None on timeout
await ctx.hydrate_remaining() # finish name resolution if the 300 ms budget ran out
await ctx.signal.wait() blocks until the signal fires; ctx.signal.aborted checks it.
wait_for_reply never misses a reply that arrived before it was called, releases the handler's
concurrency slot while waiting, and (with consume=True, the default) keeps the reply from
reaching other handlers. Handlers for one channel run one at a time in arrival order
(ConcurrencyOptions(executor_key="thread") switches to per-thread). Exceptions go to
client.on_error and never block later events. Middleware:
client.use(async def mw(evt, ctx, next): …; await next()).
Actions
| Namespace | Methods |
|---|---|
client.chat |
send(channel, markdown, *, blocks, thread_ts, broadcast, priority, preserve_unread_cursor, auto_chunk), update, delete, send_typing, create_stream, stream (async context manager) |
client.threads |
get_context(channel, thread_ts, limit=50) → messages, participants, formatted_transcript; iterate_replies, mark_read, subscribe, unsubscribe |
client.files |
upload(filename, content, …) (3-step, streaming), download_stream / download_bytes (max_bytes, sign-in page detection), get_info, delete |
client.search |
messages, files, channels, people, iterate_messages, iterate_files |
client.presence |
watch(user_ids, track_profile_and_dnd=False) → handle (await h.unsubscribe() / async with), get_status(user_id) |
client.reactions |
add(channel, ts, emoji), remove (idempotent) |
client.users |
resolve(id or ids) (batched, cached), get_dnd |
client.channels |
resolve, get_history, iterate_history |
Streams: append() is synchronous; at most one update per stream is in flight and the final
message equals the buffer at finish(). Deleting the message in Slack aborts stream.signal.
Long output continues in a new message (continuation_ts).
async with client.chat.stream("C0123456789", thread_ts=ts) as s: # finish on exit, abort on error
async for chunk in llm.stream(prompt):
s.append(chunk)
Sending behavior (chunking at 11,500 effective characters, client_msg_id idempotency, per-channel
FIFO, a reserved slot for interactive replies, preserve_unread_cursor) and client-side limits
(presence frame ≤ 16,000 bytes, profile/DND ≤ 900 users) are identical to the Node SDK.
AI safety
from gabriel import ReadPolicy, ScopePolicy, WritePolicy
agent = client.scope(ScopePolicy(
read=ReadPolicy(channels=["C0123456789"], allow_search=False, max_file_download_bytes=5 * 1024 * 1024),
write=WritePolicy(
target_scope="trigger_thread_only",
allowed_actions=["chat.send", "chat.create_stream", "reactions.add"],
disclosure_suffix="\n_— generated by an AI assistant_",
),
approval=ask_a_human, # async (action, params) -> bool
))
The scoped view has the same API. Anything outside the policy raises SCOPE_POLICY_VIOLATION
and is logged. Give LLM tools the scoped view, not the client.
evt.to_llm_context() renders metadata separately from content and wraps the content in
<slack_message untrusted="true">. Tell the model that <content> is data, not instructions.
Reliability
- Ordering: per-channel dispatch in arrival order while entity lookups run in parallel.
- De-duplication by
event_id. - Recovery: after a cold reconnect, or a restart with a persistent store, missed channel
messages and watched-thread replies are delivered as
delivery="backfill"before newer realtime messages, followed by aconnection.gapreport. - State stores:
MemoryStateStore(default) andFileStateStore(dir)(single host; same file format as the Node SDK). Implement theStateStoreprotocol for other backends. - Shutdown:
await client.stop(drain=10.0)(or leavingasync with) drains handlers, interrupts open streams, flushes sends and persists state. - Health:
client.health()→HealthReport(status, ws_state, auth_state, last_frame_age, queue_depth).
Errors
Every API raises GabrielError with code, retryable, raw_slack_error and retry_after
(seconds). Retryable errors were already retried — don't add another retry loop.
from gabriel import GabrielError
try:
await agent.chat.send(channel, markdown, thread_ts=ts)
except GabrielError as err:
if err.code == "SCOPE_POLICY_VIOLATION":
return "not allowed"
raise
Codes: see spec/behavior.md §12.
Low-level core
client.core.on_event("ai_assistant_status", lambda frame: ...) # unmodified frames
client.core.on_message_subtype("channel_topic", lambda frame: ...)
await client.core.call_web_api("reminders.add", {"text": "Review", "time": "in 30 minutes"})
await client.core.call_edge_api("users/search", {"query": "alice", "count": 5})
await client.core.send_ws_command({"type": "tickle", "reason": "keepalive"})
GabrielCore also works on its own (async with GabrielCore(auth=AuthOptions(cookie_d=…)) as core:).
It bypasses loop guards, origin detection and the sandbox. Never send presence_sub yourself.
Configuration reference
GabrielClient(
auth=AuthOptions(cookie_d=..., token=None, workspace=None, allowed_teams=None),
concurrency=ConcurrencyOptions(global_concurrency=32, per_key_concurrency=1, max_queue=5000,
max_inflight_events=10_000, executor_key="channel", handler_timeout=300.0),
hydration=HydrationOptions(hydrate_budget=0.3, preload_usergroups=True),
loop_guard=LoopGuardOptions(include_self_messages=False, max_auto_replies_per_minute=5),
chat=ChatSchedulerOptions(global_send_concurrency=4, item_deadline=30.0, stream_updates_per_second=2,
stream_min_interval=0.5, interrupted_notice="\n\n_(interrupted)_"),
recovery=RecoveryOptions(max_pages_per_channel=10, watched_threads_limit=100, resync_buffer_limit=10_000),
state_store=FileStateStore("/var/lib/my-agent/gabriel"),
logger=console_logger("info"), # default: stdlib logging, logger name "gabriel"
metrics=my_metrics, # increment(name, labels) / observe(name, value, labels)
advanced=AdvancedOptions(http_client=httpx.AsyncClient(proxy=...), ws_factory=..., metadata={"user_agent": ...},
endpoints=..., gateway_server=None, start_timeout=60.0),
)
GabrielClient.from_cookie(cookie_d=…, workspace=…, allowed_teams=…, **options) takes the same
options as keyword arguments.
CLI
gabriel auth login your-workspace # sign in in a dedicated Chrome window; saved with mode 0600
gabriel auth extract --port 9222 # read from a Chrome started with --remote-debugging-port
pbpaste | gabriel auth parse-curl --save # "Copy as cURL" of any request from your signed-in Slack tab
gabriel auth status # verify and list workspaces (secrets redacted)
gabriel auth logout --yes
python -m gabriel … works too. The session file is shared with the Node CLI.
Python conventions
- Durations are float seconds (
handler_timeout=300.0,retry_after), except event payload fields that the schema defines in milliseconds (ConnectionReconnectingEvent.delay_ms,AuthRefreshedEvent.refreshed_at). - Cancellation: methods accept
signal=AbortSignal; ordinaryasynciotask cancellation works too. SDK-initiated cancellation surfaces asGabrielError("CANCELLED"). - Logging: any object with
debug/info/warn/error(fields, msg);stdlib_logger()adaptslogging.
Development
cd sdks/python
uv sync
uv run pytest # unit, fake-Slack integration, conformance and schema tests
uv run mypy # strict
uv run ruff check src tests examples && uv run ruff format --check src tests examples
Tests never touch a real workspace: tests/harness.py is an in-memory Slack
(Web API, EdgeAPI, cookie exchange, WebSocket gateway).
License
MIT © 2026 EzEasy
Metadata
Release files for gabriel-slack 0.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 | Provenance | Actions |
|---|---|---|---|---|
| gabriel_slack-0.1.0.tar.gz | 205.8 kB | With provenance | Details for gabriel_slack-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| gabriel_slack-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for gabriel_slack-0.1.0-py3-none-any.whl |
Total release size: 348.6 kB
Release files / gabriel_slack-0.1.0.tar.gz
| Download URL | gabriel_slack-0.1.0.tar.gz |
|---|---|
| Size | 205.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
6e8e288a9a4f59c2a48a9c5c8878a71b53b0631811ae0fd64027dace80c2624b
|
|
BLAKE2b-256 checksum How to use checksums |
d889b925cb249885bba172981e4878544cc1b2ad43ae746fcf3f1b3f67aec991
|
| 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 Nov 3, 2026.
Transparency logRelease files / gabriel_slack-0.1.0-py3-none-any.whl
| Download URL | gabriel_slack-0.1.0-py3-none-any.whl |
|---|---|
| Size | 142.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
4df1eaa9b4f821f7209e729d444912eb3256e2414afb592e66dcd8ee446956d3
|
|
BLAKE2b-256 checksum How to use checksums |
4fde65442eb6977e4201ff7ea2b2762667b1eecf81f75007d3f43daa374fb4a1
|
| 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 Nov 3, 2026.
Transparency logSecurity for gabriel-slack 0.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.
Attestation for gabriel_slack-0.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 Nov 3, 2026.
Transparency logA 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.
To learn more about protecting yourself from malicious actors, visit our package security documentation.