Generate a new AI agent with all boilerplate (agent class, events, config with form duality, memory, LLM streaming, i18n, BDD tests, entry point). Includes pattern catalog, execution model reference, and implementation checklist. Use when user says "create new agent", "scaffold an agent", "generate agent boilerplate", "add AI agent", "new workflow agent", or "build an agent for X". Do NOT use for debugging agents (use /debug-agent), event infrastructure (use /nats-events), or process orchestration (use /scaffold-process).
설치
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
Generate a new AI agent with all boilerplate (agent class, events, config with form duality, memory, LLM streaming, i18n, BDD tests, entry point). Includes pattern catalog, execution model reference, and implementation checklist. Use when user says "create new agent", "scaffold an agent", "generate agent boilerplate", "add AI agent", "new workflow agent", or "build an agent for X". Do NOT use for debugging agents (use /debug-agent), event infrastructure (use /nats-events), or process orchestration (use /scaffold-process).
allowed-tools
Read, Write, Bash, Grep, Glob
Scaffold a New AI Agent
Generate all boilerplate for a new AI agent. The agent name/description should be provided via $ARGUMENTS.
Before You Start
Read the agent scope guide: packages/agent/CLAUDE.md
Agents are Dispatchable Workflows — directed acyclic graphs where nodes are @step methods and edges are typed
Events. The framework is a custom workflow engine — not LlamaIndex workflows.
Fundamental invariant: Steps declare data requirements, not execution order. The dispatcher decides when to execute
each step based on which events are available.
Inheritance chain: DispatchableWorkflow → Agent → your concrete agent
Key design properties:
Stateless: Each step gets a fresh agent() instance. Never use self for state.
Distributed: Consecutive steps may run on different servers via NATS/JetStream load balancing.
Event-driven: All inter-step communication is through typed events. No shared memory, no direct calls.
Data-injected: Step parameters are resolved by the dispatcher via dependency injection (type annotation → value).
Additional invariant properties:
Any step can depend on any event, including the original StartEvent, regardless of how many steps have executed
since. Events persist until run completion (Rule R6).
Parallel execution is automatic: Steps with independent dependencies execute concurrently. The workflow is a
dependency graph, not a sequence.
Multiple steps for the same run may execute in parallel if their dependencies are independently satisfied.
Critical difference — think data dependencies, not control flow:
# WRONG mental model (imperative, top-down):# "First do A, then B, then C"asyncdefrun(self):
a = awaitself.step_a()
b = awaitself.step_b(a)
c = awaitself.step_c(b)
# CORRECT mental model (declarative, bottom-up):# "C needs B's output. B needs A's output. A needs the start event."@step()asyncdefstep_a(self, event: StartEvent) -> EventA: ...
@step()asyncdefstep_b(self, event: EventA) -> EventB: ...
@step()asyncdefstep_c(self, event: EventB) -> StopEvent: ...
Avoid pass-through pollution — each event should contain only the data it semantically represents:
# WRONG (top-down thinking, passing data forward):@step()asyncdefretrieve(self, event: UserMessageEvent) -> RetrieveEvent:
nodes = await retriever.retrieve(event.user_query)
return RetrieveEvent(nodes=nodes, user_query=event.user_query) # Passing query forward!@step()asyncdefrespond(self, event: RetrieveEvent) -> StopEvent:
returnawait generate(event.user_query, event.nodes) # Using passed-through data# CORRECT (bottom-up thinking, direct dependencies):@step()asyncdefretrieve(self, event: UserMessageEvent) -> RetrieveEvent:
nodes = await retriever.retrieve(event.user_query)
return RetrieveEvent(nodes=nodes) # Only retrieval-specific data@step()asyncdefrespond(
self,
retrieve_event: RetrieveEvent,
user_event: UserMessageEvent, # Direct dependency on original event) -> StopEvent:
returnawait generate(user_event.user_query, retrieve_event.nodes)
Design approach: Sketch top-down to understand logical flow, then refine bottom-up to identify true data
dependencies. For each step ask: What is the minimal set of data this step requires?
Parallel processing: one step produces multiple events, another collects all results.
@step()asyncdeffan_out(self, _: StartEvent) -> list[TaskEvent]:
return [TaskEvent(task=t) for t in tasks]
@step()asyncdefprocess(self, event: TaskEvent) -> ResultEvent:
return ResultEvent(result=process(event.task)) # Runs once per TaskEvent@step()asyncdeffan_in(self, results: FixedList(ResultEvent, N)) -> StopEvent:
# Waits for exactly N results, then fires oncereturn StopEvent(combined=[r.result for r in results])
Key: Use FixedList(EventType, N) when the count is known at compile time.
Key: The precondition function uses the same DI system as @step — it can receive events, config, context. The
precondition re-evaluates on each new event arrival until it returns True.
For workflows requiring multiple human interactions, create distinct subclasses. The dispatcher differentiates steps by
event type—using the same base type for multiple interactions causes ambiguity.
UserMessageEvent is a chat UI contract. It is the canonical entry point for chat interfaces (OpenWebUI, Teams,
Slack). Keep its payload minimal — every field added to it (or to a subclass) raises the bar for every chat client. If
your agent needs a richer entry payload and the publisher is not a generic chat UI (e.g. a custom domain front-end or
another agent delegating via AgentInTheLoop), subclass StartEvent directly and accept
UserMessageEvent | YourStartEvent on the relevant steps.
Rule of thumb: If a step consumes it → ControlEvent. If only the UI needs it → DisplayEvent. If both →
ControlAndDisplayEvent. Most custom agent events are ControlEvent.
Self-awareness pattern: Detection/answer for meta-questions about the agent itself ("What can you do?", "Who are
you?") is added per agent, not inherited. For a conversational agent: (1) define two thin @step methods —
detect_meta_question_step (on UserMessageEvent) and answer_meta_question_step — each delegating to the shared free
functions do_detect_meta_question / do_answer_meta_question / summarize_workflow_for_meta_answer and passing
agent_config.task_llm (both are auxiliary work; task_llm falls back to the main llm when unset). There is no
separate stop step — answer_meta_question_step returns the terminal LLMStopEvent itself; (2) gate every raw
UserMessageEvent entry step with _clear: NotAMetaQuestionEvent | None = None and combine its precondition with
check_passed_meta_question_gate. The compliance test self_awareness/tests/test_self_awareness_wiring.py fails if a
self-aware agent defines a partial step set or leaves an entry step ungated. See RAGAgent for the reference
implementation.
If the agent also adopts conversation metadata (title + follow-up questions, see ADR 2026_06_18), the meta branch
needs its own wiring too — it doesn't inherit the normal-flow wiring automatically. Add a third @step
(generate_meta_question_title_step) triggered on the same MetaQuestionDetectedEvent as answer_meta_question_step
(so the dispatcher runs both concurrently — title only needs the user's question, not the meta answer, so it must not
wait for it), calling generate_title. Then have answer_meta_question_step call generate_follow_up_questions (not
the bundled generate_conversation_metadata — title is already handled by the parallel step) on the returned
LLMStopEvent.chat_messages before returning it. See RAGAgent for both.
The Stop Event Constraint
No step may depend on StopEvent or any subclass as an input. When StopEvent is emitted, the run terminates.
# ILLEGAL: Depending on stop event@step()asyncdefcleanup(self, stop: LLMStopEvent) -> CleanupEvent:
... # Never executes# CORRECT: Use non-stop intermediate event, then explicit stop@step()asyncdefrespond(self, event: Input) -> LLMEvent: # Not LLMStopEventreturnawait displayer.display_llm_stream(..., as_stop_step=False)
@step()asyncdefcleanup(self, llm: LLMEvent) -> CleanupEvent:
...
@step(precondition=cleanup_complete)asyncdeffinalize(self, cleanup: CleanupEvent) -> StopEvent:
return StopEvent()
Custom Event Template
Create one file per event in agents/{AgentName}/events/:
# packages/agent/swiss_ai_hub/agent/agents/{AgentName}/events/{EventName}.pyfrom swiss_ai_hub.core.nats.events.control.ControlEvent import ControlEvent
class {EventName}(ControlEvent):
"""Carries {description} from step X to step Y."""
field_name: str
another_field: list[str] = []
Events auto-register on import — no manual registration needed. See /nats-events for the full event hierarchy.
Events as Flow Carriers
Events serve two distinct purposes:
Data carriers: Transporting values between steps
Flow carriers: Controlling execution order independent of data
A step may depend on an event solely to ensure execution ordering:
classPathA(Event):
pass# No fields - pure flow control@step()asyncdefhandle_path_a(self, _: PathA, original: StartEvent) -> StopEvent:
# Underscore signals: "I need this event for flow control, not data"
process(original.data)
return StopEvent()
The _: EventType convention indicates dependency on an event's existence rather than its contents. Essential for
conditional branching, sequencing without data coupling, and synchronization barriers.
Step 4: Create Agent Class
# packages/agent/swiss_ai_hub/agent/agents/{AgentName}/{AgentName}.pyfrom typing import ClassVar
from swiss_ai_hub.core.nats.events.control.stop.StopEvent import StopEvent
from swiss_ai_hub.core.nats.events.user.UserMessageEvent import UserMessageEvent
from swiss_ai_hub.agent.agents.Agent import Agent
from swiss_ai_hub.agent.i18n.AgentLocaleString import AgentLocaleString
from swiss_ai_hub.agent.workflow.decorators.step import step
from .events.{EventName} import {EventName}
class {AgentName}(Agent):
name: ClassVar[AgentLocaleString] = AgentLocaleString.from_i18n_path(
"agent.{agent_name}.metadata.name"
)
description: ClassVar[AgentLocaleString] = AgentLocaleString.from_i18n_path(
"agent.{agent_name}.metadata.description"
)
icon: ClassVar[str] = "mage:robot" @step(
name=AgentLocaleString.from_i18n_path("agent.{agent_name}.steps.start"),
description=AgentLocaleString.from_i18n_path("agent.{agent_name}.steps.start_description"),
icon="mage:play",
)asyncdefstart_step(self, event: UserMessageEvent) -> {EventName}:
return {EventName}(field_name=event.message)
@step(
name=AgentLocaleString.from_i18n_path("agent.{agent_name}.steps.end"),
icon="mage:check",
)asyncdefend_step(self, event: {EventName}) -> StopEvent:
return StopEvent()
Create translation files in packages/agent/i18n/translations/agent/:
# {agent_name}.en.ymlen:agent:
{agent_name}:metadata:name:"{Agent Display Name}"description:"{Agent description for Admin UI}"steps:start:"Start"start_description:"Receives user message and begins processing"end:"Finish"
Create matching files for de, fr, it locales with translated strings.
Translation lookup order: Local → Agent Scope → Library → English fallback
# packages/agent/swiss_ai_hub/agent/agents/{AgentName}/tests/features/{agent_name}.feature
Feature: {Agent Display Name}
Scenario: Happy path
Given an agent "{AgentName}" is running
When the user sends "test message"
Then the agent produces a "StopEvent"
And the agent does not produce an "ExceptionEvent"
Test Implementation
# packages/agent/swiss_ai_hub/agent/agents/{AgentName}/tests/test_{agent_name}.pyimport pytest
from pytest_bdd import given, scenario, then, when
from swiss_ai_hub.core.events.agent.control.stop.stop_event import StopEvent
from swiss_ai_hub.core.events.agent.user.user_message_event import UserMessageEvent
from swiss_ai_hub.core.testing.asyncio_utils.bdd import async_test
from swiss_ai_hub.agent.agents.{agent_name}.{agent_name} import {AgentName}
from swiss_ai_hub.agent.agents.{agent_name}.configs.{agent_name}_config import {AgentName}Config
from swiss_ai_hub.agent.runners.agent_test_runner import AgentTestRunner
@scenario("features/{agent_name}.feature", "Happy path")deftest_happy_path():
pass@given('an agent "{AgentName}" is running')@async_testasyncdefrunner(request):
config = {AgentName}Config.as_form()
asyncwith AgentTestRunner(agent_type={AgentName}, agent_config=config).test_run() as runner:
request.node.runner = runner
yield runner
@when('the user sends "test message"')@async_testasyncdefsend_message(runner):
await runner.send_event_from_topic(UserMessageEvent(message="test message"))
@then('the agent produces a "StopEvent"')@async_testasyncdefcheck_stop(runner):
stop = await runner.wait_for_event(StopEvent, timeout=30)
assert stop isnotNone@then('the agent does not produce an "ExceptionEvent"')@async_testasyncdefcheck_no_exception(runner):
assertnot runner.has_exception_event
Key Test Assertions
Method
Purpose
runner.has_start_event
Check if StartEvent was received
runner.has_stop_event
Check if StopEvent was received
runner.has_exception_event
Check if ExceptionEvent was received
runner.get_events_of_class(cls)
Get all events of a specific type
runner.wait_for_event(cls, timeout)
Wait for a specific event (async)
runner.send_event_from_topic(e)
Send an event to the agent
For AITL tests, use runner.ensure_dependent_agent_stream(agent_class).
Unit Testing (Direct Step Invocation)
Individual steps can be tested by calling them directly, bypassing the dispatcher:
Agent discovery is automatic — AgentRunner responds to AgentClassDiscoveryRequestEvent with the agent's metadata,
form schema, event specs, and workflow graph. No manual registration needed.
Verify the agent is discoverable:
cd packages/agent && uv run python -c "from swiss_ai_hub.agent.agents.{AgentName}.{AgentName} import {AgentName}; print({AgentName}.get_steps())"
Implementation Checklist
Before Coding
Read packages/agent/CLAUDE.md
Identified the pattern from the catalog above
Studied the matching playground example
Sketched the event DAG on paper (events as edges, steps as nodes)
During Coding
Agent class extends Agent with name, description, icon as ClassVar[AgentLocaleString]
All @step methods are async, use type annotations, return events
No instance state on self — all state in RunContext / ThreadContext
Events inherit from the correct base class (see table above)
One class per file, file name matches class name
Config uses form duality pattern with as_form() classmethod
Form constraints use Ge(), Le() etc. — not Pydantic's ge=, le=
i18n translations in all 4 locales (de, en, fr, it)
Entry point in app/{agent_name}/main.py
After Coding
Every event produced by a step is consumed by another step (no dead ends)
Every execution path reaches StopEvent
StopEvent is returned alone (not in a list with other events)
Optional params have synchronization (precondition or max_executions_per_run)
BDD tests pass: cd packages/agent && uv run pytest tests/ -k "{agent_name}" -v
Agent is importable:
uv run python -c "from swiss_ai_hub.agent.agents.{AgentName}.{AgentName} import {AgentName}"