Skip to content

Effects

Effects are typed, fire-and-forget side-effect events. Unlike scheduled actions (which execute within a phase convergence loop and can cascade), effects are dispatched after commit and are terminal — handlers cannot produce new StateCommands, actions, or effects.

Typical use cases: audit logging, external webhook calls, metric emission, notification delivery.

Every effect type implements EffectSpec:

pub trait EffectSpec: 'static + Send + Sync {
/// Unique string identifier for this effect kind.
const KEY: &'static str;
/// The payload carried by the effect.
/// Must be serializable so the runtime can store it as JSON internally.
type Payload: Serialize + DeserializeOwned + Send + Sync + 'static;
}

Crate path: awaken::model::EffectSpec (re-exported from awaken-runtime-contract)

KEY must be globally unique across all registered effects. Convention: "<plugin>.<effect_name>", e.g. "audit.record".

Call StateCommand::emit::<E>(payload) from any hook or action handler:

use awaken::{StateCommand, StateError};
async fn run(&self, ctx: &PhaseContext) -> Result<StateCommand, StateError> {
let mut cmd = StateCommand::new();
cmd.emit::<AuditEffect>(AuditPayload {
action: "user_login".into(),
actor: "agent-1".into(),
})?;
Ok(cmd)
}

Effects are collected in StateCommand::effects and dispatched only after the command’s state mutations are committed. Tools can also emit effects by including them in the StateCommand returned alongside a ToolResult.

TypedEffect is the runtime’s type-erased envelope for effects:

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TypedEffect {
pub key: String,
pub payload: JsonValue,
}

Two methods bridge between the typed and erased worlds:

  • TypedEffect::from_spec::<E>(&payload) — serializes a typed payload into a TypedEffect. Takes the payload by reference (&E::Payload). Called internally by StateCommand::emit.
  • TypedEffect::decode::<E>(&self) — deserializes the stored JSON payload back into the concrete E::Payload type.

You rarely need to use TypedEffect directly; StateCommand::emit and the handler trait handle serialization transparently.

Effect handlers implement TypedEffectHandler<E>:

#[async_trait]
pub trait TypedEffectHandler<E>: Send + Sync + 'static
where
E: EffectSpec,
{
async fn handle_typed(
&self,
payload: E::Payload,
snapshot: &Snapshot,
) -> Result<(), String>;
}

Key points:

  • The handler receives the post-commit Snapshot, so it sees the state that includes the mutations from the command that emitted the effect.
  • The return type is Result<(), String> — not Result<(), StateError>. Handlers report errors as plain strings; the runtime logs them but does not propagate them.

Register a handler in your plugin’s register() method:

fn register(&self, r: &mut PluginRegistrar) -> Result<(), StateError> {
r.register_effect::<AuditEffect, _>(AuditEffectHandler)?;
Ok(())
}

Duplicate registrations (same E::KEY) produce StateError::EffectHandlerAlreadyRegistered.

  1. Collect — A hook, action handler, or tool calls cmd.emit::<E>(payload). The TypedEffect is appended to StateCommand::effects.

  2. Validate — When submit_command processes the command, every effect key is checked against the registered handlers. If any key has no registered handler, the command is rejected with StateError::UnknownEffectHandler before any state is committed. This is a fail-fast guarantee.

  3. Commit — State mutations (MutationBatch) are committed to the store.

  4. Dispatch — After a successful commit, each effect is dispatched to its handler via handle_typed(payload, snapshot). The snapshot reflects post-commit state.

  5. Error handling — Handler failures are logged and counted in EffectDispatchReport but do not roll back the commit or block subsequent effects. The runtime continues dispatching remaining effects.

Hook / Tool Runtime
| |
|-- StateCommand (with effects) ->|
| |-- validate all effect keys
| | (fail-fast if unknown)
| |-- commit state mutations
| |-- dispatch effects sequentially
| | handler(payload, snapshot)
| |-- return SubmitCommandReport
|<--------------------------------|

Define an effect:

use awaken::EffectSpec;
use serde::{Deserialize, Serialize};
/// Payload for audit log entries.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditPayload {
pub action: String,
pub actor: String,
}
/// Effect spec for audit logging.
pub struct AuditEffect;
impl EffectSpec for AuditEffect {
const KEY: &'static str = "audit.record";
type Payload = AuditPayload;
}

Emit it from a phase hook:

use async_trait::async_trait;
use awaken::{PhaseContext, PhaseHook, StateCommand, StateError};
pub struct AuditHook;
#[async_trait]
impl PhaseHook for AuditHook {
async fn run(&self, ctx: &PhaseContext) -> Result<StateCommand, StateError> {
let mut cmd = StateCommand::new();
cmd.emit::<AuditEffect>(AuditPayload {
action: "phase_entered".into(),
actor: "system".into(),
})?;
Ok(cmd)
}
}

Handle the effect:

use async_trait::async_trait;
use awaken::{Snapshot, TypedEffectHandler};
pub struct AuditEffectHandler;
#[async_trait]
impl TypedEffectHandler<AuditEffect> for AuditEffectHandler {
async fn handle_typed(
&self,
payload: AuditPayload,
_snapshot: &Snapshot,
) -> Result<(), String> {
tracing::info!(
action = %payload.action,
actor = %payload.actor,
"audit effect dispatched"
);
// In production: write to external audit store, send webhook, etc.
Ok(())
}
}

Wire it all together in a plugin:

use awaken::{Plugin, PluginDescriptor, PluginRegistrar, StateError};
use awaken::model::Phase;
pub struct AuditPlugin;
impl Plugin for AuditPlugin {
fn descriptor(&self) -> PluginDescriptor {
PluginDescriptor::new("audit", "Audit logging via effects")
}
fn register(&self, r: &mut PluginRegistrar) -> Result<(), StateError> {
r.register_effect::<AuditEffect, _>(AuditEffectHandler)?;
r.register_phase_hook("audit", Phase::RunStart, AuditHook)?;
Ok(())
}
}
EffectsScheduled Actions
TimingPost-commitWithin phase convergence loop
Can cascadeNoYes (handlers return StateCommand)
Can produce StateCommandNoYes
Failure handlingLogged, non-blockingError propagated to caller
State visibilityPost-commit snapshotPre-commit context
Use caseExternal I/O, logging, metricsInternal control flow, state manipulation

Choose effects when you need to trigger external side-effects that should not influence the agent’s state convergence. Choose scheduled actions when the handler needs to mutate state or schedule further work.