Skip to content

System Events

DCAF automatically emits intermittent_update stream events for key moments in agent execution — when the model starts reasoning, when a tool begins executing, and when a skill is accessed. This page explains how to configure, customise, and extend those built-in signals.


What Are System Events?

While your agent is running, DCAF emits transient status messages to the client UI at certain points in the lifecycle. These are called system events — they are generated by the framework itself, not by your tool code.

By default, three are active:

When it fires Default text
Model begins reasoning "Thinking..."
A tool begins executing "Calling tool: {tool_name}"
A skill is accessed "Loading skill: {skill_name}"

These appear on the client as intermittent_update stream events — the same type that emit_update() sends from your code. The UI shows them while the agent is working and clears them when the next piece of content arrives.


The SystemEvent Descriptor

Each system event is represented by a SystemEvent object. Import the pre-built constants — these are the intended public API:

from dcaf.core import THINKING, THINKING_COMPLETE, TOOL_STARTED, TOOL_COMPLETED, TOOL_FAILED, SKILL_LOADED
Constant Fires when Default text Available variables Default
THINKING Model begins reasoning "Thinking..." ✓ on
THINKING_COMPLETE Model finishes reasoning "Done thinking" off
TOOL_STARTED A tool begins executing "Calling tool: {tool_name}" {tool_name} ✓ on
TOOL_COMPLETED A tool finishes successfully "Done: {tool_name}" {tool_name} off
TOOL_FAILED A tool raises an exception "Failed: {tool_name}" {tool_name}, {error} off
SKILL_LOADED A skill is accessed by the agent "Loading skill: {skill_name}" {skill_name} ✓ on

THINKING, TOOL_STARTED, and SKILL_LOADED are on by default. All others are off by default — opt in when you want them.


Configuring System Events

Pass system_events to the Agent constructor.

Keep the defaults (no configuration needed)

agent = Agent(tools=[...])
# Equivalent to:
agent = Agent(tools=[...], system_events=None)

Disable all system events

agent = Agent(tools=[...], system_events=False)

Choose specific events

from dcaf.core import THINKING, TOOL_STARTED, TOOL_COMPLETED, SKILL_LOADED

agent = Agent(
    tools=[...],
    system_events=[THINKING, TOOL_STARTED, SKILL_LOADED, TOOL_COMPLETED],
)

Only the events in the list will fire. The default three (THINKING, TOOL_STARTED, SKILL_LOADED) are not added automatically when you pass a list — you are taking full control.


Customising the Text

Simple text override

Replace the default string with your own. Template variables in {curly_braces} are still supported:

from dcaf.core import THINKING, TOOL_STARTED, TOOL_COMPLETED

agent = Agent(
    tools=[...],
    system_events=[
        THINKING.with_text("Working..."),
        TOOL_STARTED.with_text("Running {tool_name}..."),
        TOOL_COMPLETED.with_text("{tool_name} complete"),
    ],
)

Custom formatter function

For dynamic logic — branching, lookups, or anything a template can't express:

from dcaf.core import TOOL_STARTED

agent = Agent(
    tools=[...],
    system_events=[
        TOOL_STARTED.with_formatter(lambda d: f"▶ {d['tool_name']}"),
    ],
)

The formatter receives a dict containing the available variables for that event (see the table above) and must return a str.


Internationalisation (i18n)

Because .with_formatter() accepts any callable, you can plug in any translation system. The formatter receives the same data dict regardless of language — all you supply is the function.

The example below shows a generic pattern. translate() is your own function (a gettext wrapper, a dict lookup, an API call — whatever fits your project):

from dcaf.core import THINKING, THINKING_COMPLETE, TOOL_STARTED, TOOL_COMPLETED, TOOL_FAILED

# Your translation function — DCAF doesn't care how it works
def translate(key: str, **kwargs) -> str:
    return translations[locale][key].format(**kwargs)

agent = Agent(
    tools=[...],
    system_events=[
        THINKING.with_formatter(
            lambda _d: translate("agent.thinking")
        ),
        THINKING_COMPLETE.with_formatter(
            lambda _d: translate("agent.thinking_done")
        ),
        TOOL_STARTED.with_formatter(
            lambda d: translate("agent.tool_started", tool_name=d["tool_name"])
        ),
        TOOL_COMPLETED.with_formatter(
            lambda d: translate("agent.tool_done", tool_name=d["tool_name"])
        ),
        TOOL_FAILED.with_formatter(
            lambda d: translate("agent.tool_failed",
                                tool_name=d["tool_name"],
                                error=d.get("error", ""))
        ),
    ],
)

The formatters are plain Python functions — they can branch on locale, call a remote service, or read from any data structure. DCAF passes the event data dict and uses whatever string you return.


Opting Into Optional Events

The off-by-default events let you bracket each phase of the lifecycle:

from dcaf.core import THINKING, THINKING_COMPLETE, TOOL_STARTED, TOOL_COMPLETED, TOOL_FAILED

agent = Agent(
    tools=[...],
    system_events=[
        THINKING,
        THINKING_COMPLETE,
        TOOL_STARTED,
        TOOL_COMPLETED,
        TOOL_FAILED.with_text("Error in {tool_name}: {error}"),
    ],
)

With this configuration the client receives events for the full lifecycle:

THINKING fires          → "Thinking..."
THINKING_COMPLETE fires → "Done thinking"

TOOL_STARTED fires   → "Calling tool: list_pods"
TOOL_COMPLETED fires → "Done: list_pods"

TOOL_STARTED fires  → "Calling tool: delete_pod"
TOOL_FAILED fires   → "Error in delete_pod: permission denied"

Relationship to emit_update()

System events and user-defined events are complementary. System events fire automatically from the framework; user-defined events let your tool code push any message at any time:

from dcaf.core import emit_update, tool

@tool(description="Search for pods")
def search_pods(query: str) -> str:
    # Your code pushing a status update (user-defined)
    emit_update(f"Searching for: {query}")
    results = _do_search(query)
    emit_update("Search complete", content={"count": len(results)})
    return format_results(results)

# The framework automatically adds:
#   "Calling tool: search_pods"   ← system event (TOOL_STARTED)
# before your tool runs

See Emitting Events for the full emit_update() / emit() reference.


Quick Reference

Goal Code
Keep defaults Agent(tools=[...])
Disable all Agent(..., system_events=False)
Choose events Agent(..., system_events=[THINKING, TOOL_STARTED, SKILL_LOADED])
Custom text TOOL_STARTED.with_text("Running {tool_name}...")
Custom skill text SKILL_LOADED.with_text("Consulting: {skill_name}")
Custom formatter TOOL_STARTED.with_formatter(lambda d: ...)
Add tool completion system_events=[..., TOOL_COMPLETED]
Bracket reasoning system_events=[THINKING, THINKING_COMPLETE, TOOL_STARTED]
Full lifecycle system_events=[THINKING, THINKING_COMPLETE, TOOL_STARTED, TOOL_COMPLETED, TOOL_FAILED, SKILL_LOADED]

See Also