Summary
Today a scheduled action can only be executed by Macro's own LLM: ActionKind has exactly one variant (Agent) and AgentTask is {model, prompt, user_prompt}. There is no way to register a self-hosted agent — one already running elsewhere with its own models, tools and memory — as a first-class Agent in Macro.
We run Hermes Agent on our own hardware (an EC2 box and a DGX). Those agents have their own scheduler, skills and curated data sources. We would like them to appear in the Agents view and be runnable on a Macro schedule, rather than either (a) reimplementing the prompt inside Macro and losing the local data pipeline, or (b) reducing the integration to a channel bot that posts output with no presence in the Agents surface.
Proposal: add a second ActionKind whose task points at an external HTTPS endpoint. Macro stays the scheduler, the system of record and the UI; the remote agent is just another executor.
Why this is mostly additive
Reading the current code, the data model already accommodates it:
ActionKind has no serde attributes (services/scheduled_action/src/domain/models.rs:52-55), so a fieldless variant serialises as the bare string "Agent". Adding RemoteAgent is wire-safe — old clients never send it.
kind is plain TEXT, not a Postgres enum, and task is untyped JSONB (crates/macro_db_client/migrations/20260416135258_scheduled_agent.up.sql). No migration is needed for the kind itself.
task is serde_json::Value in Rust (models.rs:74-75, :90-91, :107-108, #[schema(value_type = Object)]) and an open map in the generated TS, and AgentTask is only deserialised lazily inside the executor (outbound/inprocess_executor/agent_task.rs:35-36). So a RemoteAgentTask needs no request/response schema change.
- The Agents UI needs no list changes. The
automations tab merges scheduled actions client-side via additionalEntities regardless of kind (apps/web/src/features/next-soup/sidebar/soup-filter-presets.ts:216-217, soup-view.tsx:233-239), and AutomationEntity is kind-agnostic (lib/queries/agent-schedule/entities.ts:41-52).
The kind is matched in exactly four places, all non-exhaustive-safe (no _ => arm), so cargo check enumerates the work:
| File:line |
What |
outbound/inprocess_executor/mod.rs:73-75 |
pre-create the run chat |
outbound/inprocess_executor/mod.rs:164-170 |
dispatch in run_job |
outbound/pg_scheduled_action_repo.rs:30-35 |
parse_kind (DB → domain) |
outbound/pg_scheduled_action_repo.rs:37-41 |
kind_to_str (domain → DB) |
Plus registering the new task schema in services/scheduled_action/src/swagger.rs, and the hardcoded kind: 'Agent' in apps/web/src/features/block-automation/component/automationUtils.ts:293,307 (its getAgentTask cast at :258-262 becomes unsound with a second kind and needs a discriminant check).
Proposed shape
pub enum ActionKind {
Agent,
RemoteAgent,
}
/// Task for an agent executing outside Macro.
pub struct RemoteAgentTask {
/// HTTPS endpoint invoked on each run.
pub endpoint_url: String,
/// Opaque prompt/payload forwarded to the remote agent.
pub user_prompt: String,
/// Optional label shown in the UI in place of a model name.
pub agent_label: Option<String>,
}
Execution would mirror the existing kind so the whole UI is inherited for free: create the run chat with agent_task::create_run_chat, POST to the endpoint, then write the response back as an assistant message. ActionExecutionRecord.resource_id already documents itself as opaque ("ID of the primary resource produced by this run… the UI interprets it based on the action kind", models.rs:131-133), and ScheduledActionUpdate::{Started,Stopped} require a chat_id, so reusing the chat is both the simplest and the most consistent option.
Three design questions we would want maintainer input on before writing code
1. Where does the remote endpoint's auth secret live? This is the only part that genuinely needs a migration and a decision. There is no secret column on scheduled_action, and putting a bearer token in task JSONB would store it in plaintext and echo it back on every GET /scheduled-actions — unlike Webhook.signing_secret, which is #[serde(default, skip_serializing)] (crates/webhook/src/domain/models.rs:414-417). Options as we see them:
- (a) Mirror the webhook model — add
signing_secret TEXT to scheduled_action, skip_serializing it, and HMAC-sign the outbound request exactly as crates/webhook does (x-macro-timestamp, x-macro-signature: v1=<hex> over timestamp.body). Most consistent with existing precedent, and means the remote agent verifies Macro rather than holding a Macro-issued credential.
- (b) Reference an existing webhook — let a
RemoteAgentTask carry a webhook_id and reuse that row's secret and headers. No new secret storage at all.
- (c) Bot-token reuse — out of scope; bots are channel principals, not schedulable.
We would prefer (a) for symmetry, or (b) if you would rather not add a column. Happy to follow whichever you prefer.
2. Retry policy. The scheduled-action executor currently has no retry — a failed run writes is_success: false and waits for the next cron tick (inprocess_executor/mod.rs:103-119). crates/webhook's policy (MAX_HTTP_ATTEMPTS = 5, delays 30/60/120/300s, domain/delivery.rs:17-23) is DB-backed and SQS-driven, so it does not drop in. Is fire-and-forget acceptable for v1, matching the existing kind, with retry deferred?
3. SSRF policy for self-hosted targets. crates/webhook deliberately blocks private and loopback addresses after real DNS resolution (outbound/http_validator.rs: is_blocked_ip covers is_private() || is_loopback() || is_link_local() and the 169.254.169.254 metadata address), defaulting to HttpsOnly. That is correct and we are not asking to weaken it. But it means a self-hosted agent on a VPC/Tailscale private address is unreachable, so operators would need a public HTTPS hostname. Two sub-questions: should RemoteAgent reuse that validator, and should the allows_local_addresses policy variant be reachable by configuration for self-hosted Macro deployments (where the operator owns both ends)?
Scope we would take on
We are happy to implement and test this, following CONTRIBUTING.md and the hexagonal rules in .agents/skills/cloud-storage-hexagonal-architecture/SKILL.md — specifically: a RemoteAgentClient port declared in domain/ports.rs, the reqwest adapter confined to outbound/remote_agent_http.rs (reqwest is not currently a dependency of this service), retry/attempt policy kept in domain/, wiring in the src/bins/service.rs composition root, and domain-level allow/deny tests rather than only HTTP status mapping. We would also regenerate the OpenAPI/orval clients and the SDK spec, and run just prepare_db if any SQL changes.
Opening this before any PR per the contributing guide. Is this a direction you would accept, and do you have a preference on question 1?
Environment
main @ 412bc8812fc0, VERSION v2026.4.28.0
- Live service confirmed at
https://agent-schedule.macro.com (/health 200, /scheduled-actions 401 unauthenticated)
Summary
Today a scheduled action can only be executed by Macro's own LLM:
ActionKindhas exactly one variant (Agent) andAgentTaskis{model, prompt, user_prompt}. There is no way to register a self-hosted agent — one already running elsewhere with its own models, tools and memory — as a first-class Agent in Macro.We run Hermes Agent on our own hardware (an EC2 box and a DGX). Those agents have their own scheduler, skills and curated data sources. We would like them to appear in the Agents view and be runnable on a Macro schedule, rather than either (a) reimplementing the prompt inside Macro and losing the local data pipeline, or (b) reducing the integration to a channel bot that posts output with no presence in the Agents surface.
Proposal: add a second
ActionKindwhose task points at an external HTTPS endpoint. Macro stays the scheduler, the system of record and the UI; the remote agent is just another executor.Why this is mostly additive
Reading the current code, the data model already accommodates it:
ActionKindhas no serde attributes (services/scheduled_action/src/domain/models.rs:52-55), so a fieldless variant serialises as the bare string"Agent". AddingRemoteAgentis wire-safe — old clients never send it.kindis plainTEXT, not a Postgres enum, andtaskis untypedJSONB(crates/macro_db_client/migrations/20260416135258_scheduled_agent.up.sql). No migration is needed for the kind itself.taskisserde_json::Valuein Rust (models.rs:74-75,:90-91,:107-108,#[schema(value_type = Object)]) and an open map in the generated TS, andAgentTaskis only deserialised lazily inside the executor (outbound/inprocess_executor/agent_task.rs:35-36). So aRemoteAgentTaskneeds no request/response schema change.automationstab merges scheduled actions client-side viaadditionalEntitiesregardless of kind (apps/web/src/features/next-soup/sidebar/soup-filter-presets.ts:216-217,soup-view.tsx:233-239), andAutomationEntityis kind-agnostic (lib/queries/agent-schedule/entities.ts:41-52).The kind is matched in exactly four places, all non-exhaustive-safe (no
_ =>arm), socargo checkenumerates the work:outbound/inprocess_executor/mod.rs:73-75outbound/inprocess_executor/mod.rs:164-170run_joboutbound/pg_scheduled_action_repo.rs:30-35parse_kind(DB → domain)outbound/pg_scheduled_action_repo.rs:37-41kind_to_str(domain → DB)Plus registering the new task schema in
services/scheduled_action/src/swagger.rs, and the hardcodedkind: 'Agent'inapps/web/src/features/block-automation/component/automationUtils.ts:293,307(itsgetAgentTaskcast at:258-262becomes unsound with a second kind and needs a discriminant check).Proposed shape
Execution would mirror the existing kind so the whole UI is inherited for free: create the run chat with
agent_task::create_run_chat, POST to the endpoint, then write the response back as an assistant message.ActionExecutionRecord.resource_idalready documents itself as opaque ("ID of the primary resource produced by this run… the UI interprets it based on the action kind",models.rs:131-133), andScheduledActionUpdate::{Started,Stopped}require achat_id, so reusing the chat is both the simplest and the most consistent option.Three design questions we would want maintainer input on before writing code
1. Where does the remote endpoint's auth secret live? This is the only part that genuinely needs a migration and a decision. There is no secret column on
scheduled_action, and putting a bearer token intaskJSONB would store it in plaintext and echo it back on everyGET /scheduled-actions— unlikeWebhook.signing_secret, which is#[serde(default, skip_serializing)](crates/webhook/src/domain/models.rs:414-417). Options as we see them:signing_secret TEXTtoscheduled_action,skip_serializingit, and HMAC-sign the outbound request exactly ascrates/webhookdoes (x-macro-timestamp,x-macro-signature: v1=<hex>overtimestamp.body). Most consistent with existing precedent, and means the remote agent verifies Macro rather than holding a Macro-issued credential.RemoteAgentTaskcarry awebhook_idand reuse that row's secret and headers. No new secret storage at all.We would prefer (a) for symmetry, or (b) if you would rather not add a column. Happy to follow whichever you prefer.
2. Retry policy. The scheduled-action executor currently has no retry — a failed run writes
is_success: falseand waits for the next cron tick (inprocess_executor/mod.rs:103-119).crates/webhook's policy (MAX_HTTP_ATTEMPTS = 5, delays30/60/120/300s,domain/delivery.rs:17-23) is DB-backed and SQS-driven, so it does not drop in. Is fire-and-forget acceptable for v1, matching the existing kind, with retry deferred?3. SSRF policy for self-hosted targets.
crates/webhookdeliberately blocks private and loopback addresses after real DNS resolution (outbound/http_validator.rs:is_blocked_ipcoversis_private() || is_loopback() || is_link_local()and the169.254.169.254metadata address), defaulting toHttpsOnly. That is correct and we are not asking to weaken it. But it means a self-hosted agent on a VPC/Tailscale private address is unreachable, so operators would need a public HTTPS hostname. Two sub-questions: shouldRemoteAgentreuse that validator, and should theallows_local_addressespolicy variant be reachable by configuration for self-hosted Macro deployments (where the operator owns both ends)?Scope we would take on
We are happy to implement and test this, following
CONTRIBUTING.mdand the hexagonal rules in.agents/skills/cloud-storage-hexagonal-architecture/SKILL.md— specifically: aRemoteAgentClientport declared indomain/ports.rs, thereqwestadapter confined tooutbound/remote_agent_http.rs(reqwestis not currently a dependency of this service), retry/attempt policy kept indomain/, wiring in thesrc/bins/service.rscomposition root, and domain-level allow/deny tests rather than only HTTP status mapping. We would also regenerate the OpenAPI/orval clients and the SDK spec, and runjust prepare_dbif any SQL changes.Opening this before any PR per the contributing guide. Is this a direction you would accept, and do you have a preference on question 1?
Environment
main@412bc8812fc0,VERSIONv2026.4.28.0https://agent-schedule.macro.com(/health200,/scheduled-actions401 unauthenticated)