Skip to content

buzz

buzz

Domo Buzz API routes.

Buzz has two, unrelated APIs. This module wraps the product API — the one with 136 endpoints that a Slack-style client is built on. Do not confuse it with the deprecated 2-endpoint "Buzz Integration API" (api.domo.com + OAuth bearer, bot registration only) — that one is a different surface with different auth and is out of scope here.

Auth: X-DOMO-Developer-Token against https://{instance}.domo.com/api (the same header crew-dcs uses everywhere else), NOT api.domo.com OAuth.

Undocumented behaviour, confirmed live on datacrew-space 2026-08-28:

  • GET /buzz/v1/channels/me returns {"me": {<channelId>: ChannelMeta}, "nextOffset": N} — a map keyed by channel id, not a list. Each ChannelMeta is a thin summary (unreadCounts, importantCounts, mentioned, channelType, lastViewedTimestamp), not a full channel.
  • Both channels/me and channels are paginated via offset/limit in, nextOffset out. list_my_channels/list_channels follow nextOffset to exhaustion by default (loop_until_end=True) and merge every page, so a caller can never silently receive only page one — pass loop_until_end=False for a single page, or maximum to cap the total fetched either way.
  • Channel history paging is a bespoke DSL — pagingMode, pagingId, pagingField, pagingLimit — not offset/limit. Plus a pile of include* expansion flags. Nothing is expanded by default.
  • POST /buzz/v1/messages is a Slack incoming-webhook payload verbatim: {alert, channel, icon_url, text, username}. The snake_case icon_url/username inside an otherwise camelCase API is intentional — do not "fix" it to camelCase.
  • POST /buzz/v1/channels/{id}/messages takes a client-supplied guid (idempotency key), generated here when the caller doesn't supply one.
  • POST /buzz/v1/sockets/authenticate is a standard Pusher channel authorizer, confirmed live on datacrew-space 2026-08-28 — not the bare-string shape a Postman sample once suggested (that claim was never live-verified and was wrong; see :func:authenticate_socket). Request is form-urlencoded (socket_id, channel_name, Content-Type: application/x-www-form-urlencoded); response is JSON {"auth": "appkey:sig"}. Sending Accept: text/plain gets a 406; sending a JSON body gets a 400.
  • GET /buzz/v1/bots returns 403 on a plain developer token — confirmed uniform on both datacrew-space and domo-community 2026-08-28, so this is not a per-instance rollout gap. GET /authorization/v1/authorities lists 129 authorities with exactly one buzz-related entry (buzz.admin, "Edit conversations and messages") and nothing bot- or integration-related, so the 403 is not grantable via a role authority — it most likely requires an integration/bot principal rather than a developer token, same family as POST /buzz/v1/messages's failure below. Bots are out of scope for this module.
  • POST /buzz/v1/messages is unusable with a developer token — see :func:post_message's docstring for the live investigation.
  • Pusher realtime fires different events for different roles relative to a post — this is the single easiest thing to get wrong here, see the "Pusher realtime" section below.
  • Buzz channel permissions can exceed the caller's instance role: a role 4 (Participant) user got a clean 403 Access Denied on DELETE /buzz/v1/channels/{id} (confirms the instance role is enforced there) but a channel-level INVITE_OTHERS grant made at invite time let that same user re-invite others on that channel — confirmed live on datacrew-space 2026-08-28. Do not infer a caller's Buzz capabilities from their instance role alone; effective rights are per-channel. (Topic invite: PUT /api/buzz/v1/topics/{channelId}/invite, body [{"id": "<userId>", "permission": "INVITE_OTHERS", "type": "USER"}] — works immediately; not wrapped by a route here yet. "ADMIN" is rejected on topic create, same as create_topic's defaultPermission/publicPermission — INVITE_OTHERS is the server's real default in both places.)
  • None-valued optional fields are omitted from every POST body here (via _clean_body, the body-side sibling of _clean_params) rather than sent as JSON null. Live-verified for create_topic's description: an omitted key and an explicit null behave identically. POST /buzz/v1/messages (post_message) couldn't be live-verified either way — see its docstring — but omission is used there too, for one consistent convention.

Buzz_CRUD_Error

Buzz_CRUD_Error(
    operation: str,
    channel_id: str | None = None,
    message: str | None = None,
    res=None,
    **kwargs
)

Bases: RouteError

Raised when a Buzz create, update, or delete operation fails.

Source code in src/crew_dcs/routes/buzz.py
137
138
139
140
141
142
143
144
145
146
147
148
149
150
def __init__(
    self,
    operation: str,
    channel_id: str | None = None,
    message: str | None = None,
    res=None,
    **kwargs,
):
    super().__init__(
        message=message or f"Buzz {operation} operation failed",
        entity_id=channel_id,
        res=res,
        **kwargs,
    )

Buzz_GET_Error

Buzz_GET_Error(
    channel_id: str | None = None,
    message: str | None = None,
    res=None,
    **kwargs
)

Bases: RouteError

Raised when a Buzz retrieval operation fails.

Source code in src/crew_dcs/routes/buzz.py
119
120
121
122
123
124
125
126
127
128
129
130
131
def __init__(
    self,
    channel_id: str | None = None,
    message: str | None = None,
    res=None,
    **kwargs,
):
    super().__init__(
        message=message or "Buzz retrieval failed",
        entity_id=channel_id,
        res=res,
        **kwargs,
    )

authenticate_socket async

authenticate_socket(
    auth: DomoAuth,
    socket_id: str,
    channel_name: str,
    *,
    context: RouteContext | None = None,
    **context_kwargs
) -> ResponseGetData

Authenticate a private Pusher channel subscription.

POST /buzz/v1/sockets/authenticate is a standard Pusher channel authorizer — live-verified on datacrew-space 2026-08-28, correcting an earlier claim (sourced from a Postman sample, never live-verified) that this returned a bare string:

  • request body: form-urlencoded, socket_id + channel_name, Content-Type: application/x-www-form-urlencoded
  • response: JSON, {"auth": "appkey:sig"} -> res.response is a dict, not a string

Also live-verified: Accept: text/plain gets a 406; a JSON body gets a 400 — this endpoint wants exactly the Pusher-standard shape above, not this API's usual JSON-everything convention.

Raises :class:Buzz_CRUD_Error if the response isn't the expected {"auth": ...} JSON shape (e.g. a bare string) — that shape must not be treated as a silent success.

Source code in src/crew_dcs/routes/buzz.py
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
@gd.route_function
@log_call(
    level_name="route",
    config=LogDecoratorConfig(
        entity_extractor=DomoEntityExtractor(),
        result_processor=DomoEntityResultProcessor(),
    ),
)
async def authenticate_socket(
    auth: DomoAuth,
    socket_id: str,
    channel_name: str,
    *,
    context: RouteContext | None = None,
    **context_kwargs,
) -> rgd.ResponseGetData:
    """Authenticate a private Pusher channel subscription.

    ``POST /buzz/v1/sockets/authenticate`` is a standard Pusher channel
    authorizer — live-verified on ``datacrew-space`` 2026-08-28, correcting
    an earlier claim (sourced from a Postman sample, never live-verified)
    that this returned a bare string:

    - request body: form-urlencoded, ``socket_id`` + ``channel_name``,
      ``Content-Type: application/x-www-form-urlencoded``
    - response: JSON, ``{"auth": "appkey:sig"}`` -> ``res.response`` is a
      ``dict``, not a string

    Also live-verified: ``Accept: text/plain`` gets a 406; a JSON body gets
    a 400 — this endpoint wants exactly the Pusher-standard shape above, not
    this API's usual JSON-everything convention.

    Raises :class:`Buzz_CRUD_Error` if the response isn't the expected
    ``{"auth": ...}`` JSON shape (e.g. a bare string) — that shape must not
    be treated as a silent success.
    """
    url = _buzz_api_url(auth, "/buzz/v1/sockets/authenticate")

    body = urlencode({"socket_id": socket_id, "channel_name": channel_name})

    res = await gd.get_data(
        url=url,
        method="POST",
        content_type="application/x-www-form-urlencoded",
        body=body,
        auth=auth,
        context=context,
    )

    if not res.is_success:
        raise Buzz_CRUD_Error(
            operation="authenticate socket",
            message="Failed to authenticate Buzz socket",
            res=res,
        )

    if not isinstance(res.response, dict) or "auth" not in res.response:
        raise Buzz_CRUD_Error(
            operation="authenticate socket",
            message=(
                "Unexpected Buzz socket authenticate response shape "
                f"(expected JSON {{'auth': ...}}, got {type(res.response).__name__})"
            ),
            res=res,
        )

    return res

create_channel_message async

create_channel_message(
    auth: DomoAuth,
    channel_id: str,
    content: str,
    guid: str | None = None,
    attachments: list[dict[str, Any]] | None = None,
    mentions_grant_permission: bool | None = None,
    *,
    context: RouteContext | None = None,
    **context_kwargs
) -> ResponseGetData

Post a message to a Buzz channel (the richer, per-channel form).

POST /buzz/v1/channels/{channelId}/messages -> body {attachments[], content, guid}.

The response nests the created message: it is {"message": {...}, "properties": {...}, "me": {...}}, so the new id is at res.response["message"]["id"] — NOT res.response["id"], which is absent. Verified live 2026-08-28. Reading it wrong yields None silently, and the next call using that id (edit, delete, react) then 400s against /buzz/v1/messages/None.

Related, for anyone building a live status surface: PUT /buzz/v1/messages/{message_id} edits a message in place (body {"content": {"text": ...}}; a bare string also works) and emits message-updated-v1 over Pusher to other channel members, so an edit is visible to everyone, not just its author. DELETE /buzz/v1/messages/{message_id} is a soft delete — the message stays in the channel reading "Message deleted." rather than disappearing. Neither is wrapped as a route here yet; the verified request shapes live in infrastructure/bonker/apps/letta-code-channels/channels/buzz-stream/src/buzz.ts.

guid is client-supplied and usable for idempotency; a fresh uuid4 is generated when not provided — attachments/guid already never reach the wire as null because of those fallbacks, but the body still goes through _clean_body for consistency with the other Buzz POST routes (see create_topic's docstring for the live finding this follows).

Source code in src/crew_dcs/routes/buzz.py
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
@gd.route_function
@log_call(
    level_name="route",
    config=LogDecoratorConfig(
        entity_extractor=DomoEntityExtractor(),
        result_processor=DomoEntityResultProcessor(),
    ),
)
async def create_channel_message(
    auth: DomoAuth,
    channel_id: str,
    content: str,
    guid: str | None = None,
    attachments: list[dict[str, Any]] | None = None,
    mentions_grant_permission: bool | None = None,
    *,
    context: RouteContext | None = None,
    **context_kwargs,
) -> rgd.ResponseGetData:
    """Post a message to a Buzz channel (the richer, per-channel form).

    ``POST /buzz/v1/channels/{channelId}/messages`` -> body
    ``{attachments[], content, guid}``.

    **The response nests the created message**: it is
    ``{"message": {...}, "properties": {...}, "me": {...}}``, so the new id is
    at ``res.response["message"]["id"]`` — NOT ``res.response["id"]``, which is
    absent. Verified live 2026-08-28. Reading it wrong yields ``None``
    silently, and the next call using that id (edit, delete, react) then 400s
    against ``/buzz/v1/messages/None``.

    Related, for anyone building a live status surface:
    ``PUT /buzz/v1/messages/{message_id}`` edits a message in place (body
    ``{"content": {"text": ...}}``; a bare string also works) and **emits
    ``message-updated-v1`` over Pusher to other channel members**, so an edit
    is visible to everyone, not just its author.
    ``DELETE /buzz/v1/messages/{message_id}`` is a **soft** delete — the
    message stays in the channel reading "Message deleted." rather than
    disappearing. Neither is wrapped as a route here yet; the verified request
    shapes live in
    ``infrastructure/bonker/apps/letta-code-channels/channels/buzz-stream/src/buzz.ts``.

    ``guid`` is client-supplied and
    usable for idempotency; a fresh uuid4 is generated when not provided —
    ``attachments``/``guid`` already never reach the wire as ``null`` because
    of those fallbacks, but the body still goes through ``_clean_body`` for
    consistency with the other Buzz POST routes (see ``create_topic``'s
    docstring for the live finding this follows).
    """
    url = _buzz_api_url(auth, f"/buzz/v1/channels/{channel_id}/messages")
    params = _clean_params({"mentionsGrantPermission": mentions_grant_permission})

    body = _clean_body(
        {
            "attachments": attachments or [],
            "content": content,
            "guid": guid or str(uuid.uuid4()),
        }
    )

    res = await gd.get_data(
        url=url,
        method="POST",
        params=params or None,
        body=body,
        auth=auth,
        context=context,
    )

    if not res.is_success:
        raise Buzz_CRUD_Error(
            operation="create message",
            channel_id=channel_id,
            message=f"Failed to post message to Buzz channel {channel_id}",
            res=res,
        )

    return res

create_topic async

create_topic(
    auth: DomoAuth,
    title: str,
    description: str | None = None,
    accesses: list[dict[str, Any]] | None = None,
    default_permission: str = "INVITE_OTHERS",
    public_permission: str = "INVITE_OTHERS",
    filters: list[dict[str, Any]] | None = None,
    pin: bool = False,
    *,
    context: RouteContext | None = None,
    **context_kwargs
) -> ResponseGetData

Create a Buzz topic (a channel with an ACL).

POST /buzz/v1/topics -> body {accesses, defaultPermission, description, filters, pin, publicPermission, title}. Response is {"channel": {...}}. accesses[] entries are {id, permission, type} where type is USER/GROUP/etc — per-conversation, per-user scoping is native here.

Live-verified on datacrew-space 2026-08-28: "ADMIN" (the sample value in Bryce's Postman collection) is NOT a valid defaultPermission/publicPermission value on create and returns a bare 400 with no field-level detail. Domo's own server default — confirmed by round-tripping a body that omits both fields — is "INVITE_OTHERS", used here instead. Empty accesses/filters lists are fine; the 400 is specific to the permission enum.

Realtime delivery follows the invite, not membership — including for the creator. Verified live 2026-08-29 with two identities, roles run both ways: an identity that CREATED a topic receives no Pusher event when another member posts into it, while an identity that was INVITED receives message-created-v2. Any integration that needs to hear messages (a bot, an agent, a listener) must be invited explicitly, even if it owns the channel. This fails silently — the socket connects, subscribes successfully, and simply never delivers anything.

accesses at create time is NOT equivalent to an invite. A topic created with accesses=[{id, permission, type}] for another user does grant that user access, but does NOT subscribe them to the channel's realtime stream: in a two-identity test on 2026-08-28 the invitee's Pusher connection received no message-created-v2 at all for messages posted into that topic. Re-running the identical test with an explicit PUT /buzz/v1/topics/{channel_id}/invite (body [{"id", "permission", "type"}], 200 + empty body) delivered the event ~0.1s after the post. If you need the other party to receive anything, invite them explicitly — accesses alone looks like it worked and is silent at runtime.

Also live-verified: an explicit JSON null and an omitted key behave identically for description — both produce a 200 with channel.description coming back null. description is nonetheless omitted from the body when None (via _clean_body) rather than sent as null, for a single consistent convention across this module's POST bodies rather than a per-route judgment call.

Source code in src/crew_dcs/routes/buzz.py
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
@gd.route_function
@log_call(
    level_name="route",
    config=LogDecoratorConfig(
        entity_extractor=DomoEntityExtractor(),
        result_processor=DomoEntityResultProcessor(),
    ),
)
async def create_topic(
    auth: DomoAuth,
    title: str,
    description: str | None = None,
    accesses: list[dict[str, Any]] | None = None,
    default_permission: str = "INVITE_OTHERS",
    public_permission: str = "INVITE_OTHERS",
    filters: list[dict[str, Any]] | None = None,
    pin: bool = False,
    *,
    context: RouteContext | None = None,
    **context_kwargs,
) -> rgd.ResponseGetData:
    """Create a Buzz topic (a channel with an ACL).

    ``POST /buzz/v1/topics`` -> body ``{accesses, defaultPermission,
    description, filters, pin, publicPermission, title}``. Response is
    ``{"channel": {...}}``. ``accesses[]`` entries are
    ``{id, permission, type}`` where ``type`` is ``USER``/``GROUP``/etc —
    per-conversation, per-user scoping is native here.

    Live-verified on ``datacrew-space`` 2026-08-28: ``"ADMIN"`` (the sample
    value in Bryce's Postman collection) is NOT a valid
    ``defaultPermission``/``publicPermission`` value on create and returns a
    bare 400 with no field-level detail. Domo's own server default —
    confirmed by round-tripping a body that omits both fields — is
    ``"INVITE_OTHERS"``, used here instead. Empty ``accesses``/``filters``
    lists are fine; the 400 is specific to the permission enum.

    **Realtime delivery follows the invite, not membership — including for
    the creator.** Verified live 2026-08-29 with two identities, roles run
    both ways: an identity that CREATED a topic receives **no** Pusher event
    when another member posts into it, while an identity that was INVITED
    receives ``message-created-v2``. Any integration that needs to *hear*
    messages (a bot, an agent, a listener) must be invited explicitly, even
    if it owns the channel. This fails silently — the socket connects,
    subscribes successfully, and simply never delivers anything.

    **``accesses`` at create time is NOT equivalent to an invite.** A topic
    created with ``accesses=[{id, permission, type}]`` for another user does
    grant that user access, but does NOT subscribe them to the channel's
    realtime stream: in a two-identity test on 2026-08-28 the invitee's Pusher
    connection received **no** ``message-created-v2`` at all for messages
    posted into that topic. Re-running the identical test with an explicit
    ``PUT /buzz/v1/topics/{channel_id}/invite`` (body
    ``[{"id", "permission", "type"}]``, 200 + empty body) delivered the event
    ~0.1s after the post. If you need the other party to *receive* anything,
    invite them explicitly — ``accesses`` alone looks like it worked and is
    silent at runtime.

    Also live-verified: an explicit JSON ``null`` and an omitted key behave
    identically for ``description`` — both produce a 200 with
    ``channel.description`` coming back ``null``. ``description`` is
    nonetheless omitted from the body when ``None`` (via ``_clean_body``)
    rather than sent as ``null``, for a single consistent convention across
    this module's POST bodies rather than a per-route judgment call.
    """
    url = _buzz_api_url(auth, "/buzz/v1/topics")

    body = _clean_body(
        {
            "accesses": accesses or [],
            "defaultPermission": default_permission,
            "description": description,
            "filters": filters or [],
            "pin": pin,
            "publicPermission": public_permission,
            "title": title,
        }
    )

    res = await gd.get_data(
        url=url,
        method="POST",
        body=body,
        auth=auth,
        context=context,
    )

    if not res.is_success:
        raise Buzz_CRUD_Error(
            operation="create topic",
            message=f"Failed to create Buzz topic {title!r}",
            res=res,
        )

    return res

delete_channel async

delete_channel(
    auth: DomoAuth,
    channel_id: str,
    *,
    context: RouteContext | None = None,
    **context_kwargs
) -> ResponseGetData

Delete a Buzz channel (topic, group chat, etc.).

DELETE /buzz/v1/channels/{channelId}.

Source code in src/crew_dcs/routes/buzz.py
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
@gd.route_function
@log_call(
    level_name="route",
    config=LogDecoratorConfig(
        entity_extractor=DomoEntityExtractor(),
        result_processor=DomoEntityResultProcessor(),
    ),
)
async def delete_channel(
    auth: DomoAuth,
    channel_id: str,
    *,
    context: RouteContext | None = None,
    **context_kwargs,
) -> rgd.ResponseGetData:
    """Delete a Buzz channel (topic, group chat, etc.).

    ``DELETE /buzz/v1/channels/{channelId}``.
    """
    url = _buzz_api_url(auth, f"/buzz/v1/channels/{channel_id}")

    res = await gd.get_data(
        url=url,
        method="DELETE",
        auth=auth,
        context=context,
    )

    if not res.is_success:
        raise Buzz_CRUD_Error(
            operation="delete",
            channel_id=channel_id,
            message=f"Failed to delete Buzz channel {channel_id}",
            res=res,
        )

    return res

get_channel_by_id async

get_channel_by_id(
    auth: DomoAuth,
    channel_id: str,
    include_permission_count: bool | None = None,
    include_pinned_count: bool | None = None,
    include_messages: bool | None = None,
    include_huddles: bool | None = None,
    include_inception_messages: bool | None = None,
    include_filters: bool | None = None,
    include_accesses: bool | None = None,
    include_recent_people: bool | None = None,
    include_my_meta: bool | None = None,
    include_video: bool | None = None,
    message_limit: int | None = None,
    huddle_limit: int | None = None,
    inception_message_limit: int | None = None,
    inception_message_sort_mode: str | None = None,
    access_limit: int | None = None,
    recent_people_limit: int | None = None,
    message_offset_id: str | None = None,
    *,
    context: RouteContext | None = None,
    **context_kwargs
) -> ResponseGetData

Get a single Buzz channel by id.

GET /buzz/v1/channels/{channelId}. Nothing is expanded by default — pass the relevant include_* flag for what you need (reactions, accesses, recent people, etc.).

Source code in src/crew_dcs/routes/buzz.py
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
@gd.route_function
@log_call(
    level_name="route",
    config=LogDecoratorConfig(
        entity_extractor=DomoEntityExtractor(),
        result_processor=DomoEntityResultProcessor(),
    ),
)
async def get_channel_by_id(
    auth: DomoAuth,
    channel_id: str,
    include_permission_count: bool | None = None,
    include_pinned_count: bool | None = None,
    include_messages: bool | None = None,
    include_huddles: bool | None = None,
    include_inception_messages: bool | None = None,
    include_filters: bool | None = None,
    include_accesses: bool | None = None,
    include_recent_people: bool | None = None,
    include_my_meta: bool | None = None,
    include_video: bool | None = None,
    message_limit: int | None = None,
    huddle_limit: int | None = None,
    inception_message_limit: int | None = None,
    inception_message_sort_mode: str | None = None,
    access_limit: int | None = None,
    recent_people_limit: int | None = None,
    message_offset_id: str | None = None,
    *,
    context: RouteContext | None = None,
    **context_kwargs,
) -> rgd.ResponseGetData:
    """Get a single Buzz channel by id.

    ``GET /buzz/v1/channels/{channelId}``. Nothing is expanded by default —
    pass the relevant ``include_*`` flag for what you need (reactions,
    accesses, recent people, etc.).
    """
    url = _buzz_api_url(auth, f"/buzz/v1/channels/{channel_id}")
    params = _clean_params(
        {
            "includePermissionCount": include_permission_count,
            "includePinnedCount": include_pinned_count,
            "includeMessages": include_messages,
            "includeHuddles": include_huddles,
            "includeInceptionMessages": include_inception_messages,
            "includeFilters": include_filters,
            "includeAccesses": include_accesses,
            "includeRecentPeople": include_recent_people,
            "includeMyMeta": include_my_meta,
            "includeVideo": include_video,
            "messageLimit": message_limit,
            "huddleLimit": huddle_limit,
            "inceptionMessageLimit": inception_message_limit,
            "inceptionMessageSortMode": inception_message_sort_mode,
            "accessLimit": access_limit,
            "recentPeopleLimit": recent_people_limit,
            "messageOffsetId": message_offset_id,
        }
    )

    res = await gd.get_data(
        url=url,
        method="GET",
        params=params or None,
        auth=auth,
        context=context,
    )

    if not res.is_success:
        raise Buzz_GET_Error(
            channel_id=channel_id,
            message=f"Failed to get Buzz channel {channel_id}",
            res=res,
        )

    return res

get_channel_messages async

get_channel_messages(
    auth: DomoAuth,
    channel_id: str,
    paging_mode: str | None = None,
    paging_id: str | None = None,
    paging_field: str | None = None,
    paging_limit: int | None = None,
    include_reactions: bool | None = None,
    include_favorited: bool | None = None,
    include_my_meta: bool | None = None,
    include_video: bool | None = None,
    include_permission_count: bool | None = None,
    include_accesses: bool | None = None,
    include_recent_people: bool | None = None,
    recent_people_limit: int | None = None,
    include_inception_messages: bool | None = None,
    inception_message_limit: int | None = None,
    inception_message_sort_mode: str | None = None,
    message_types: str | None = None,
    *,
    context: RouteContext | None = None,
    **context_kwargs
) -> ResponseGetData

Get message history for a Buzz channel.

GET /buzz/v1/channels/{channelId}/messages. Paging is a bespoke DSL — pagingMode/pagingId/pagingField/pagingLimit — NOT offset/limit. Every include_* flag is passed through as given; nothing is expanded unless requested.

paging_mode is only valid together with paging_id. Sending pagingMode=BEFORE (or AFTER) on its own returns a bare 400 Bad Request, as does pairing it with paging_field but no id; omitting it entirely returns 200. Verified live on datacrew-space 2026-08-28 across seven parameter combinations. So the first page must not set paging_mode — only a follow-up page anchored on a message id may. Callers that default paging_mode to a sort direction will 400 on every first call.

Source code in src/crew_dcs/routes/buzz.py
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
@gd.route_function
@log_call(
    level_name="route",
    config=LogDecoratorConfig(
        entity_extractor=DomoEntityExtractor(),
        result_processor=DomoEntityResultProcessor(),
    ),
)
async def get_channel_messages(
    auth: DomoAuth,
    channel_id: str,
    paging_mode: str | None = None,
    paging_id: str | None = None,
    paging_field: str | None = None,
    paging_limit: int | None = None,
    include_reactions: bool | None = None,
    include_favorited: bool | None = None,
    include_my_meta: bool | None = None,
    include_video: bool | None = None,
    include_permission_count: bool | None = None,
    include_accesses: bool | None = None,
    include_recent_people: bool | None = None,
    recent_people_limit: int | None = None,
    include_inception_messages: bool | None = None,
    inception_message_limit: int | None = None,
    inception_message_sort_mode: str | None = None,
    message_types: str | None = None,
    *,
    context: RouteContext | None = None,
    **context_kwargs,
) -> rgd.ResponseGetData:
    """Get message history for a Buzz channel.

    ``GET /buzz/v1/channels/{channelId}/messages``. Paging is a bespoke DSL —
    ``pagingMode``/``pagingId``/``pagingField``/``pagingLimit`` — NOT
    offset/limit. Every ``include_*`` flag is passed through as given;
    nothing is expanded unless requested.

    **``paging_mode`` is only valid together with ``paging_id``.** Sending
    ``pagingMode=BEFORE`` (or ``AFTER``) on its own returns a bare 400 Bad
    Request, as does pairing it with ``paging_field`` but no id; omitting it
    entirely returns 200. Verified live on ``datacrew-space`` 2026-08-28
    across seven parameter combinations. So the first page must not set
    ``paging_mode`` — only a follow-up page anchored on a message id may.
    Callers that default ``paging_mode`` to a sort direction will 400 on
    every first call.
    """
    url = _buzz_api_url(auth, f"/buzz/v1/channels/{channel_id}/messages")
    params = _clean_params(
        {
            "pagingMode": paging_mode,
            "pagingId": paging_id,
            "pagingField": paging_field,
            "pagingLimit": paging_limit,
            "includeReactions": include_reactions,
            "includeFavorited": include_favorited,
            "includeMyMeta": include_my_meta,
            "includeVideo": include_video,
            "includePermissionCount": include_permission_count,
            "includeAccesses": include_accesses,
            "includeRecentPeople": include_recent_people,
            "recentPeopleLimit": recent_people_limit,
            "includeInceptionMessages": include_inception_messages,
            "inceptionMessageLimit": inception_message_limit,
            "inceptionMessageSortMode": inception_message_sort_mode,
            "messageTypes": message_types,
        }
    )

    res = await gd.get_data(
        url=url,
        method="GET",
        params=params or None,
        auth=auth,
        context=context,
    )

    if not res.is_success:
        raise Buzz_GET_Error(
            channel_id=channel_id,
            message=f"Failed to get messages for Buzz channel {channel_id}",
            res=res,
        )

    return res

get_socket_config async

get_socket_config(
    auth: DomoAuth,
    *,
    context: RouteContext | None = None,
    **context_kwargs
) -> ResponseGetData

Get the caller's Pusher realtime configuration.

GET /buzz/v1/sockets/me -> {applicationKey, host, port, sslPort, channel, buzzChannel}. channel is per-user (subscribe to your own stream); buzzChannel is company-wide.

applicationKey is per-instance — live-verified values: datacrew-space -> cf468a162798e3ce79ce, domo-community -> e640cbc9816caa81b971. Always read it from this endpoint at runtime; never hardcode it.

See the "Sockets (Pusher realtime)" section comment above this function for the poster-vs-observer event role split — the single easiest thing to get wrong when consuming the channel/buzzChannel this returns.

Source code in src/crew_dcs/routes/buzz.py
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
@gd.route_function
@log_call(
    level_name="route",
    config=LogDecoratorConfig(
        entity_extractor=DomoEntityExtractor(),
        result_processor=DomoEntityResultProcessor(),
    ),
)
async def get_socket_config(
    auth: DomoAuth,
    *,
    context: RouteContext | None = None,
    **context_kwargs,
) -> rgd.ResponseGetData:
    """Get the caller's Pusher realtime configuration.

    ``GET /buzz/v1/sockets/me`` -> ``{applicationKey, host, port, sslPort,
    channel, buzzChannel}``. ``channel`` is per-user (subscribe to your own
    stream); ``buzzChannel`` is company-wide.

    ``applicationKey`` is **per-instance** — live-verified values:
    ``datacrew-space`` -> ``cf468a162798e3ce79ce``, ``domo-community`` ->
    ``e640cbc9816caa81b971``. Always read it from this endpoint at runtime;
    never hardcode it.

    See the "Sockets (Pusher realtime)" section comment above this function
    for the poster-vs-observer event role split — the single easiest thing
    to get wrong when consuming the channel/buzzChannel this returns.
    """
    url = _buzz_api_url(auth, "/buzz/v1/sockets/me")

    res = await gd.get_data(
        url=url,
        method="GET",
        auth=auth,
        context=context,
    )

    if not res.is_success:
        raise Buzz_GET_Error(message="Failed to get Buzz socket configuration", res=res)

    return res

list_channels async

list_channels(
    auth: DomoAuth,
    offset: int | None = None,
    limit: int | None = None,
    loop_until_end: bool = True,
    maximum: int | None = None,
    debug_loop: bool = False,
    *,
    context: RouteContext | None = None,
    **context_kwargs
) -> ResponseGetData

List all Buzz channels visible to the caller.

GET /buzz/v1/channels -> {"channels": [...], "me": {}, "nextOffset": N, "properties": {}}.

Paginated to exhaustion by default (loop_until_end=True): follows nextOffset — a falsy (None/0) nextOffset, an empty page, or a nextOffset that fails to advance all signal the last page — and concatenates every page's channels list. A caller can never silently receive just page one this way. Pass loop_until_end=False for exactly one page (the returned response["nextOffset"] then tells you whether more exist); maximum caps the total number of channels collected in either mode.

Source code in src/crew_dcs/routes/buzz.py
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
@gd.route_function
@log_call(
    level_name="route",
    config=LogDecoratorConfig(
        entity_extractor=DomoEntityExtractor(),
        result_processor=DomoEntityResultProcessor(),
    ),
)
async def list_channels(
    auth: DomoAuth,
    offset: int | None = None,
    limit: int | None = None,
    loop_until_end: bool = True,
    maximum: int | None = None,
    debug_loop: bool = False,
    *,
    context: RouteContext | None = None,
    **context_kwargs,
) -> rgd.ResponseGetData:
    """List all Buzz channels visible to the caller.

    ``GET /buzz/v1/channels`` -> ``{"channels": [...], "me": {}, "nextOffset": N, "properties": {}}``.

    Paginated to exhaustion by default (``loop_until_end=True``): follows
    ``nextOffset`` — a falsy (``None``/``0``) ``nextOffset``, an empty page,
    or a ``nextOffset`` that fails to advance all signal the last page — and
    concatenates every page's ``channels`` list. A caller can never silently
    receive just page one this way. Pass ``loop_until_end=False`` for exactly
    one page (the returned ``response["nextOffset"]`` then tells you whether
    more exist); ``maximum`` caps the total number of channels collected in
    either mode.
    """
    url = _buzz_api_url(auth, "/buzz/v1/channels")

    current_offset = offset or 0
    all_channels: list[dict] = []
    res: rgd.ResponseGetData | None = None
    next_offset: int | None = None
    last_page: dict[str, Any] = {}

    while True:
        params = _clean_params({"offset": current_offset, "limit": limit})
        res = await gd.get_data(
            url=url,
            method="GET",
            params=params or None,
            auth=auth,
            context=context,
        )

        if not res.is_success:
            raise Buzz_GET_Error(message="Failed to list Buzz channels", res=res)

        page = res.response if isinstance(res.response, dict) else {}
        page_channels = page.get("channels") or []
        all_channels.extend(page_channels)
        next_offset = page.get("nextOffset")
        last_page = page

        if debug_loop:
            await logger.debug(
                f"list_channels: {len(all_channels)} channels so far "
                f"(page: {len(page_channels)}, nextOffset: {next_offset})"
            )

        if (
            not loop_until_end
            or not page_channels
            or not next_offset
            or next_offset == current_offset
            or (maximum and len(all_channels) >= maximum)
        ):
            break

        current_offset = next_offset

    if maximum and len(all_channels) > maximum:
        all_channels = all_channels[:maximum]

    return rgd.ResponseGetData(
        status=getattr(res, "status", 200),
        response={
            "channels": all_channels,
            "me": last_page.get("me", {}),
            "nextOffset": next_offset,
            "properties": last_page.get("properties", {}),
        },
        is_success=True,
        request_metadata=res.request_metadata,
        additional_information=res.additional_information,
    )

list_my_channels async

list_my_channels(
    auth: DomoAuth,
    offset: int | None = None,
    limit: int | None = None,
    loop_until_end: bool = True,
    maximum: int | None = None,
    debug_loop: bool = False,
    *,
    context: RouteContext | None = None,
    **context_kwargs
) -> ResponseGetData

List the calling user's Buzz channels.

GET /buzz/v1/channels/me. The response is a map keyed by channel id — {"me": {<channelId>: ChannelMeta}, "nextOffset": N} — not a list. Do not assume list semantics on response["me"].

Paginated to exhaustion by default (loop_until_end=True): follows nextOffset — a falsy (None/0) nextOffset, an empty page, or a nextOffset that fails to advance all signal the last page — and merges every page's me map by key. A caller can never silently receive just page one this way. Pass loop_until_end=False for exactly one page (the returned response["nextOffset"] then tells you whether more exist); maximum caps the total number of entries merged in either mode.

Source code in src/crew_dcs/routes/buzz.py
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
@gd.route_function
@log_call(
    level_name="route",
    config=LogDecoratorConfig(
        entity_extractor=DomoEntityExtractor(),
        result_processor=DomoEntityResultProcessor(),
    ),
)
async def list_my_channels(
    auth: DomoAuth,
    offset: int | None = None,
    limit: int | None = None,
    loop_until_end: bool = True,
    maximum: int | None = None,
    debug_loop: bool = False,
    *,
    context: RouteContext | None = None,
    **context_kwargs,
) -> rgd.ResponseGetData:
    """List the calling user's Buzz channels.

    ``GET /buzz/v1/channels/me``. The response is a **map keyed by channel
    id** — ``{"me": {<channelId>: ChannelMeta}, "nextOffset": N}`` — not a
    list. Do not assume list semantics on ``response["me"]``.

    Paginated to exhaustion by default (``loop_until_end=True``): follows
    ``nextOffset`` — a falsy (``None``/``0``) ``nextOffset``, an empty page,
    or a ``nextOffset`` that fails to advance all signal the last page — and
    merges every page's ``me`` map by key. A caller can never silently
    receive just page one this way. Pass ``loop_until_end=False`` for exactly
    one page (the returned ``response["nextOffset"]`` then tells you whether
    more exist); ``maximum`` caps the total number of entries merged in
    either mode.
    """
    url = _buzz_api_url(auth, "/buzz/v1/channels/me")

    current_offset = offset or 0
    merged_me: dict[str, Any] = {}
    res: rgd.ResponseGetData | None = None
    next_offset: int | None = None

    while True:
        params = _clean_params({"offset": current_offset, "limit": limit})
        res = await gd.get_data(
            url=url,
            method="GET",
            params=params or None,
            auth=auth,
            context=context,
        )

        if not res.is_success:
            raise Buzz_GET_Error(message="Failed to list my Buzz channels", res=res)

        page = res.response if isinstance(res.response, dict) else {}
        page_me = page.get("me") or {}
        merged_me.update(page_me)
        next_offset = page.get("nextOffset")

        if debug_loop:
            await logger.debug(
                f"list_my_channels: {len(merged_me)} channels so far "
                f"(page: {len(page_me)}, nextOffset: {next_offset})"
            )

        if (
            not loop_until_end
            or not page_me
            or not next_offset
            or next_offset == current_offset
            or (maximum and len(merged_me) >= maximum)
        ):
            break

        current_offset = next_offset

    if maximum and len(merged_me) > maximum:
        merged_me = dict(list(merged_me.items())[:maximum])

    return rgd.ResponseGetData(
        status=getattr(res, "status", 200),
        response={"me": merged_me, "nextOffset": next_offset},
        is_success=True,
        request_metadata=res.request_metadata,
        additional_information=res.additional_information,
    )

post_message async

post_message(
    auth: DomoAuth,
    channel: str,
    text: str,
    username: str | None = None,
    icon_url: str | None = None,
    alert: bool = False,
    *,
    context: RouteContext | None = None,
    **context_kwargs
) -> ResponseGetData

Post a Slack-shaped incoming-webhook message.

POST /buzz/v1/messages -> body {alert, channel, icon_url, text, username}. This is a Slack incoming-webhook payload adopted verbatim by Domo: icon_url and username are snake_case on purpose, inside an otherwise camelCase API. Do NOT rename them to iconUrl/userName — that is the regression this route guards against. username/ icon_url are omitted from the body when None (via _clean_body) rather than sent as JSON null.

Live investigation on datacrew-space 2026-08-28: this endpoint 400s on every body shape tried against a plain developer token, regardless of whether channel is a topic id or title, whether username/ icon_url are real strings, null, or omitted entirely — even POST /buzz/v1/channels/{channelId}/messages (this module's other, working message route) succeeds against the same channel in the same run, isolating the failure to this endpoint specifically. No live signal on null-vs-omit was obtainable here since nothing succeeds either way; most likely this Slack-webhook-shaped endpoint needs an incoming-webhook registration this token doesn't carry (paralleling GET /buzz/v1/bots's 403 on a plain developer token — see the module docstring). Omission is used anyway, for consistency with the rest of this module's POST bodies and because it is provably no worse than null for the one shape that does work (create_topic).

This endpoint is unusable with a developer token, confirmed again 2026-08-28: 400 on every body shape tried (the full Slack shape above, text+channel only, and an empty body); text alone with no channel gets a 500 instead of a 400, so the handler does read and reject channel rather than bailing before touching the body at all. Errors are opaque either way — {"status":400,"statusReason":"Bad Request","message":"Bad Request","toe":"<trace id>"} — no field-level detail. toe is a Domo trace id, worth quoting verbatim to Domo support if this is ever escalated. Practical consequence: the Slack-shaped username/icon_url display-name override is not an available way to give a bot its own identity in Buzz — use create_channel_message (POST /buzz/v1/channels/{channelId}/messages) instead, which works but has no display-name override of its own.

Source code in src/crew_dcs/routes/buzz.py
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
@gd.route_function
@log_call(
    level_name="route",
    config=LogDecoratorConfig(
        entity_extractor=DomoEntityExtractor(),
        result_processor=DomoEntityResultProcessor(),
    ),
)
async def post_message(
    auth: DomoAuth,
    channel: str,
    text: str,
    username: str | None = None,
    icon_url: str | None = None,
    alert: bool = False,
    *,
    context: RouteContext | None = None,
    **context_kwargs,
) -> rgd.ResponseGetData:
    """Post a Slack-shaped incoming-webhook message.

    ``POST /buzz/v1/messages`` -> body ``{alert, channel, icon_url, text,
    username}``. This is a Slack incoming-webhook payload adopted verbatim by
    Domo: ``icon_url`` and ``username`` are snake_case on purpose, inside an
    otherwise camelCase API. Do NOT rename them to ``iconUrl``/``userName`` —
    that is the regression this route guards against. ``username``/
    ``icon_url`` are omitted from the body when ``None`` (via
    ``_clean_body``) rather than sent as JSON ``null``.

    Live investigation on ``datacrew-space`` 2026-08-28: this endpoint 400s
    on every body shape tried against a plain developer token, regardless of
    whether ``channel`` is a topic id or title, whether ``username``/
    ``icon_url`` are real strings, ``null``, or omitted entirely — even
    ``POST /buzz/v1/channels/{channelId}/messages`` (this module's *other*,
    working message route) succeeds against the same channel in the same
    run, isolating the failure to this endpoint specifically. No live signal
    on null-vs-omit was obtainable here since nothing succeeds either way;
    most likely this Slack-webhook-shaped endpoint needs an incoming-webhook
    registration this token doesn't carry (paralleling ``GET /buzz/v1/bots``'s
    403 on a plain developer token — see the module docstring). Omission is
    used anyway, for consistency with the rest of this module's POST bodies
    and because it is provably no worse than ``null`` for the one shape that
    *does* work (``create_topic``).

    This endpoint is **unusable with a developer token**, confirmed again
    2026-08-28: 400 on every body shape tried (the full Slack shape above,
    ``text``+``channel`` only, and an empty body); ``text`` alone with no
    ``channel`` gets a **500** instead of a 400, so the handler does read
    and reject ``channel`` rather than bailing before touching the body at
    all. Errors are opaque either way — ``{"status":400,"statusReason":"Bad
    Request","message":"Bad Request","toe":"<trace id>"}`` — no field-level
    detail. ``toe`` is a Domo trace id, worth quoting verbatim to Domo
    support if this is ever escalated. Practical consequence: the Slack-shaped
    ``username``/``icon_url`` display-name override is **not** an available
    way to give a bot its own identity in Buzz — use
    ``create_channel_message`` (``POST
    /buzz/v1/channels/{channelId}/messages``) instead, which works but has
    no display-name override of its own.
    """
    url = _buzz_api_url(auth, "/buzz/v1/messages")

    body = _clean_body(
        {
            "alert": alert,
            "channel": channel,
            "icon_url": icon_url,
            "text": text,
            "username": username,
        }
    )

    res = await gd.get_data(
        url=url,
        method="POST",
        body=body,
        auth=auth,
        context=context,
    )

    if not res.is_success:
        raise Buzz_CRUD_Error(
            operation="post message",
            channel_id=channel,
            message=f"Failed to post Slack-shaped message to Buzz channel {channel}",
            res=res,
        )

    return res