What's New: Journey Steps via MCP, Smarter Simulate, and Batch Send Fixes
2026-04-28
Several improvements shipped this week. Here's what changed.
Journey steps via MCP
MCP callers could already create journeys, activate them, and check their status. The missing piece was step authoring - there was no way to add or edit the steps themselves without dropping to the CLI or the REST API directly.
Two new MCP tools close that gap: add_journey_step and update_journey_step.
add_journey_step posts a step to an existing journey:
{
"journey_id": "jrn_abc123",
"step_order": 1,
"step_type": "send",
"config": {
"templateSlug": "trial-day-3",
"mailboxId": "mbx_xyz"
}
}
Step types are send, delay, branch, and end. For delay steps, the config field is delayMinutes - not minutes or delayMs. The tool description spells this out explicitly to prevent silent misconfigurations.
update_journey_step patches a step by ID. All fields are optional, but the config object replaces the stored value entirely rather than merging. If you want to change delayMinutes from 60 to 120 and keep other config fields, pass all of them - not just the field you're updating.
The journey_status tool already returns every step on a journey, so there's no separate list tool. Use journey_status to read the current step IDs, then update by ID.
This means an MCP-native agent can now build and manage a complete multi-step journey without leaving MCP context: create the journey, add steps, activate it, enroll contacts, and check status. Full docs are in the MCP server reference.
Simulate now surfaces all blocking reasons at once
The send simulate endpoint checks whether a proposed send would be allowed before you commit to it. Until now, it stopped at the first blocking reason and returned it as the reason field. That made iteration slow - you'd fix the reported block, run simulate again, find the next one, repeat.
That behavior has changed. Simulate now runs every check in the same order that a real send would, collects all detected blocks, and returns them in a blockingReasons array. The most important one is still surfaced in reason so existing callers don't break.
Before (trial tenant, marketing template with a lint error and no physical address):
{
"wouldAllow": false,
"reason": "trial_not_activated"
}
After:
{
"wouldAllow": false,
"reason": "missing_physical_address",
"blockingReasons": [
"missing_physical_address",
"trial_not_activated",
"template_lint_failed"
]
}
One simulate call now tells you everything blocking the send so you can fix the whole stack in one pass. The blockingReasons field is optional and only present when there are blocks, so callers that only read reason continue to work as before.
Batch send improvements
Three fixes to the send batch command this week, all stemming from the same root issue: the CLI expected { email, dedupeKey, payload } objects in the recipients array, but agents passing a simpler string array (["a@b.com", "c@d.com"]) got back a silent failure - each recipient mapped to undefined fields and hit a database constraint.
String recipients now work. The CLI now accepts both formats. A plain string like "a@b.com" is treated as the recipient address and gets an auto-generated dedupeKey. Object entries without an explicit dedupeKey also get one auto-generated. The accepted shapes now match what simulate-batch already accepted.
Database error details are scrubbed. When a send fails at the infrastructure layer, the reason field in the batch response used to pass through the raw Postgres error message - exposing schema details like column names and constraint names. Those messages now map to internal_error. Policy engine reasons (rate_limited, suppressed, trial_not_activated, and others) pass through unchanged since they're stable identifiers callers are expected to handle.
Exit code is non-zero on total failure. If every recipient in the batch was blocked or failed, send batch now exits with a non-zero status. Previously it exited 0 regardless, which made failure invisible to scripts checking the exit code.
Consent records now default grantedAt to the request time
When you record consent with POST /v1/agent/consent and don't include an explicit grantedAt timestamp, the API now sets it to the time of the request. Before, it stored null, which meant a consent check would return { grantedAt: null } seconds after you recorded a successful opt-in - not a reliable audit trail for GDPR purposes.
Explicit timestamps are still respected - useful if you're importing historical consent records and need to backdate. The only change is the default.
Standalone revocations (where you set revokedAt without a grantedAt) still store null for grantedAt. Fabricating a grant timestamp for a revocation of unknown prior consent would be worse than leaving it null.
The record consent response now returns the full stored record rather than just { id }, so you can confirm the stored grantedAt without a round-trip.
These changes are live. The MCP tools are documented in the MCP server reference. If you're using journeys and want context on the journey model, What's New: Journeys in the Portal and Automatic Warmup Scheduling covers what landed the previous week.
If you're building agents that send email, create a free account or read the docs.