Skip to content

Support pull-based custom channels with lifecycle hooks and programmatic ingress #711

Description

@cihantas

What problem are you trying to solve?

Eve’s custom-channel API works well for push-based integrations where an external platform calls an HTTP or WebSocket route. It does not currently provide a safe way to implement pull-based transports such as:

  • Telegram getUpdates long polling
  • Queue consumers
  • Message brokers
  • IMAP polling
  • Local sockets or IPC
  • Database notification listeners
  • Device and event-stream consumers

A custom channel receives Eve’s send function only inside an HTTP or WebSocket route handler:

POST("/message", async (request, { send }) => {
  await send(...);
});

A pull-based connector needs to start a background consumer when the agent runtime becomes ready and dispatch messages without an inbound HTTP request. There is currently no supported lifecycle or ingress API for doing that.

Starting a loop at module scope is unsafe because channel modules may be imported during discovery, compilation, builds, and hot reloads. It can create duplicate consumers or perform external operations when no runtime is ready.

As a workaround, the connector must run as a separate process and forward every event into an Eve HTTP route on localhost. In a multi-agent application, each agent may run on a dynamic port, so the sidecar must also discover which server is the correct coordinator and reconnect whenever it changes.

This makes a logically self-contained custom connector require an additional process supervisor, internal HTTP transport, dynamic service discovery, and duplicate-consumer protection.

Proposed solution

Add lifecycle hooks and programmatic ingress to custom channels.

For example:

import { defineChannel } from "eve/channels";

export default defineChannel({
  async start(context) {
    const {
      signal,
      dispatch,
      logger,
    } = context;

    let offset = 0;

    while (!signal.aborted) {
      const updates = await getUpdates({
        offset,
        timeout: 30,
        signal,
      });

      for (const update of updates) {
        await dispatch(update.message.text, {
          auth: telegramAuth(update),
          continuationToken: telegramContinuationToken(update),
          state: telegramState(update),
        });

        offset = update.update_id + 1;
      }
    }
  },

  async stop() {
    // Optional cleanup beyond aborting the lifecycle signal.
  },

  events: {
    async "message.completed"(event, channel) {
      await deliverToTelegram(event.message, channel.state);
    },
  },
});

The lifecycle context could provide:

interface ChannelLifecycleContext {
  signal: AbortSignal;
  dispatch: SendFn;
  logger: Logger;
  runtime: {
    agentId: string;
    environment: "development" | "production";
  };
}

Desired behavior:

  • start runs only after the agent runtime and workflow handlers are ready.
  • dispatch has the same semantics as route-handler send.
  • signal aborts before shutdown or replacement.
  • Eve prevents duplicate lifecycle instances for the same compiled channel.
  • Hot reload stops the previous instance before starting the replacement.
  • Startup failures are reported as channel health errors.
  • Long-running tasks can use bounded restart and backoff behavior.
  • Multi-agent applications start lifecycle hooks only for the owning agent.
  • Builds, discovery, and static module imports never invoke lifecycle hooks.
  • Unsupported environments fail clearly rather than silently starting duplicate consumers.

It may also be useful to expose channel health through /eve/v1/info:

{
  "channels": [
    {
      "id": "telegram",
      "status": "running",
      "transport": "background",
      "startedAt": "..."
    }
  ]
}

For hosts that cannot support persistent background work, Eve could reject the configuration during startup or require an explicit deployment capability:

defineChannel({

  runtime: "persistent",
  start(context) {
    // ...
  },
});

Alternatives considered

External sidecar

Run the pull consumer as a separate process and forward events into a custom HTTP route. This works, but requires process supervision, internal HTTP authentication, runtime URL discovery, and coordination across restarts.

Module-scope background loop

Start polling when the channel module is imported. This can create duplicate consumers during discovery, builds, hot reloads, or multi-worker execution. It also has no reliable shutdown or readiness signal.

Self-calling HTTP route

Expose a custom route and have a background script call it over localhost. This preserves the existing channel API but turns an internal dispatch into an unnecessary network boundary and does not solve lifecycle ownership.

Platform webhooks

Use a public webhook rather than polling. This is appropriate for hosted deployments, but not for local machines, private networks, message brokers, local IPC, or integrations that only provide pull-based APIs.

Provider-specific polling support

Eve could add long polling directly to its Telegram channel. That solves Telegram but leaves the same limitation for queues, IMAP, brokers, and other pull-based integrations. A general custom-channel lifecycle API would support all of these use cases.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions