Standalone Sender for Custom Channels

Make your praisonai.channels plugin deliver scheduled briefs without a resident gateway

Declare a standalone_sender on your channel’s descriptor and praisonai schedule tick delivers to it from OS cron, CI, or a scale-to-zero worker. You don’t need a gateway running.

Diagram

yes

no

⏰ OS cron / CI

praisonai schedule tick

resolve_standalone_sender

Bundled platform?

📦 bundled sender

📇 descriptor.standalone_sender

✅ chat

Quick Start

  1. Schedule an agent to a custom channel

    Use the same deliver token you would for a built-in platform. Only the channel name changes.

    python
    from praisonaiagents import Agent
    from praisonaiagents.tools import schedule_add
    
    agent = Agent(name="Brief", instructions="Summarise the morning news", tools=[schedule_add])
    
    # Saves the job to the schedule store that `praisonai schedule tick` reads.
    agent.start("Every hour, send me a morning brief. Deliver to intranet:ops.")

    Then run the tick from OS cron with no gateway running:

    bash
    0 * * * *  INTRANET_WEBHOOK_URL=... praisonai schedule tick
  2. Add a standalone_sender to the plugin's descriptor

    Add one method to the descriptor your praisonai.channels plugin already ships:

    python
    # my_pkg/intranet.py
    import json
    import os
    import urllib.request
    
    from praisonaiagents.bots import ChannelField
    
    
    class IntranetDescriptor:
        config_fields = [
            ChannelField("webhook_url", required=True, secret=True, env="INTRANET_WEBHOOK_URL"),
        ]
        system_prompt_hint = "You are posting to the company intranet chat."
    
        # The scheduler finds this on the registered descriptor.
        def standalone_sender(self, target, text):
            body = json.dumps({"room": target.channel_id, "text": text}).encode()
            req = urllib.request.Request(
                os.environ["INTRANET_WEBHOOK_URL"],
                data=body,
                headers={"Content-Type": "application/json"},
            )
            urllib.request.urlopen(req, timeout=30)  # raises on HTTP errors
    
    
    class IntranetBot:
        # Your existing live-gateway adapter. A class-level descriptor is found
        # even in a fresh `schedule tick` process.
        channel_descriptor = IntranetDescriptor()
        ...
    toml
    # pyproject.toml
    [project.entry-points."praisonai.channels"]
    intranet = "my_pkg.intranet:IntranetBot"

How It Works

Diagram
Intranet chatIntranetDescriptor_registryresolve_standalone_senderScheduledAgentExecutorpraisonai schedule tickIntranet chatIntranetDescriptor_registryresolve_standalone_senderScheduledAgentExecutorpraisonai schedule tickrun due job (deliver intranet:ops)resolve(“intranet”)bundled senders → no matchget_platform_descriptor(“intranet”)IntranetDescriptordescriptor.standalone_senderstandalone_sender(target, text)POST messagerecord delivered=True

Precedence

A bundled name always wins.

OrderSourceWins when
1Bundled senderChannel is telegram, slack, discord, whatsapp, or signal
2standalone_sender on the registered ChannelDescriptorChannel is registered by a plugin and its descriptor declares a callable standalone_sender
3None → executor records delivery_errorChannel is unknown, has no descriptor, or the descriptor has no standalone_sender

If the descriptor lookup raises (a broken plugin, for example), the resolver logs at debug level and returns None. The run records delivery_error and the tick keeps going.


Sync or Async

Either form works. The executor awaits the result only when it is awaitable.

Async

python
class IntranetDescriptor:
    async def standalone_sender(self, target, text):
        await post_message(target.channel_id, text)  # raises on failure

Sync

python
class IntranetDescriptor:
    def standalone_sender(self, target, text):
        post_message_blocking(target.channel_id, text)  # raises on failure

The sender receives the scheduler’s DeliveryTarget (target.channel, target.channel_id, target.thread_id) and the text to post.


End-to-end: cron deployment

  1. Install the plugin and set its secret
    bash
    pip install my-intranet-channel
    export INTRANET_WEBHOOK_URL=https://chat.example.internal/hooks/abc
  2. Create a job with the custom-channel target
    bash
    praisonai schedule add "morning-brief" -s hourly -m "Morning brief" --deliver intranet:ops
  3. Schedule the tick in OS cron
    bash
    # crontab -e
    0 * * * *  INTRANET_WEBHOOK_URL=https://chat.example.internal/hooks/abc praisonai schedule tick
  4. Gateway off, cron fires, message lands

    Cron fires, the job runs, the resolver falls through to your descriptor, and the brief arrives in ops. Run history shows delivered=True.


Best Practices

Keep the sender stateless

Don’t keep open sockets or cached connections. Make one push per call so the short-lived tick process exits cleanly.

Read secrets from env vars

The sender runs out of process, so the onboarding wizard never runs there. Every secret it needs should have an env= fallback on its ChannelField.

Let failures raise

Don’t catch and swallow errors inside the sender. When an error reaches the executor, it records delivery_error, and operators can see the real reason in run history.

Prefer async for network I/O

Both forms work. The executor runs on asyncio, so a sync sender blocks the event loop for as long as its HTTP call takes.


Related