Skip to content

Guide 00 — The Simple Agent

The first rung of the ladder. One import, one required dependency, four methods:

from symfonic import Agent
from symfonic.core.providers import AnthropicProvider

agent = Agent(AnthropicProvider())
result = await agent.run("Say hello.")
print(result.text)

There is no FrameworkConfig, no tenant scope, no session, no context manager you are obliged to enter, no background flush, and nothing printed about capabilities you did not ask for. Everything above is the whole API of this rung.

New composition and existing applications

Start new compositions with Agent and add the capabilities you need. Memory, delegation and other capabilities are not enabled by this minimal example. Existing SymfonicAgent applications retain their compatibility entry point, subject to existing configuration validation. Moving to 11.0 separates adoption from retirement; it does not introduce new deprecations. See Guide 22 — Capability-Architecture Migration for the whole ladder.

The public surface

from symfonic import ... exports exactly twelve names:

Name What it is
Agent the facade below
AgentResult what run() returns
AgentEvent what stream() yields
Message one transcript entry (role, content, tool_calls)
ToolCall one call, with result or error and duration_ms
TokenUsage input_tokens, output_tokens, total_tokens
CapabilityConfig the typed seam for optional capability composition
Attachment a file handed to a turn
StructuredOutputError raised when a typed result cannot be produced
ConfigurationError you passed something the agent will not accept
ContractViolationError you used the agent in a way it forbids
SymfonicError the base of both

Importing the top-level module executes no package import: from symfonic import Agent pulls in the facade chain and nothing else — no symfonic.memory, no fastapi, no MCP client. That is a mechanically checked property, not an aspiration, and it is why the simple path stays fast to import in a cold container.

Constructing

Agent(
    model_provider,          # the one required dependency
    *,
    instructions=None,       # the system prompt, used verbatim
    tools=(),                # fixed at construction
    capabilities=(),         # optional capability contributions
    max_model_rounds=None,   # provider round trips per run(); None = the default
)

max_model_rounds bounds how many provider round trips one run() may take before the kernel stops with stop_reason="tool_limit". None keeps the facade default. An agent migrated off SymfonicAgent passes the budget it already had (config.agent.recursion_limit counts graph steps, roughly two per round), because a loop bounded lower than the path it replaced returns a partial answer as if it were the whole one.

model_provider is any object with a get_chat_model() method — the shipped providers, or your own class. instructions=None sends no system message at all; the framework substitutes no default of its own.

Construction validates and stores, and does nothing else: no connection, no task, no file read, no environment read, no chat model. The first side effect of an Agent is the provider call inside run().

Running one turn

result = await agent.run("What is the capital of France?")

result.text          # the assistant text
result.messages      # the full transcript of this turn
result.tool_calls    # every call, with results and durations
result.usage         # TokenUsage
result.stop_reason   # why the loop ended
result.run_id        # correlates with every event of this run

run() is a coroutine and there is no run_sync() wrapper: a wrapper that calls asyncio.run explodes inside an already-running event loop, and hiding that would be worse than not offering it.

Tools

Any @symfonic_tool, any LangChain BaseTool, or a plain annotated callable:

from symfonic import Agent
from symfonic.core import symfonic_tool

@symfonic_tool(name="celsius_to_f")
def celsius_to_f(celsius: float) -> float:
    """Convert Celsius to Fahrenheit."""
    return celsius * 9 / 5 + 32

agent = Agent(provider, tools=[celsius_to_f])
result = await agent.run("What is 21C in Fahrenheit?")

The tool loop runs to completion inside run(). Every round trip is on result.tool_calls, in order, with the value the tool returned or the error it raised.

Structured output

Pass a Pydantic model as output_type and read result.output:

from pydantic import BaseModel

class City(BaseModel):
    name: str
    country: str

result = await agent.run("Where is the Eiffel Tower?", output_type=City)
result.output.country     # "France"

output_type is per call, not per agent: two calls on the same instance may ask for different shapes, because the plan is compiled per invocation and nothing mutable is carried on the agent. A response that cannot be coerced raises StructuredOutputError.

Streaming

final = None
async for event in agent.stream("Write a haiku about rain."):
    if event.kind == "text_delta":
        print(event.text, end="")
    elif event.kind == "done":
        final = event.result          # the same AgentResult run() returns

The kinds are thinking, text_delta, tool_call, tool_result, done, error, cancelled. Exactly one terminal event — done, error or cancelled — arrives per stream, and it is the last one.

stream() is a plain function that returns an async iterator, not an async generator function: argument validation happens at the call site, so a ConfigurationError cannot surface after you believed the stream had already started. async for event in agent.stream(...) reads identically either way.

Multi-turn, without a session

The simple Agent is stateless on purpose. You hold the transcript and hand it back:

first = await agent.run("My name is Ada.")
second = await agent.run("What is my name?", history=first.messages)

When you want the framework to hold it for you — sessions, transcripts, checkpoints, restart recovery — that is rung 2, and it lives on SymfonicAgent and the conversation runtime service. See Conversation Persistence.

Attachments

import base64
from symfonic import Agent, Attachment

pdf = base64.b64encode(Path("contract.pdf").read_bytes()).decode()

result = await agent.run(
    "Summarise this contract.",
    attachments=[
        Attachment(
            kind="document",
            source_type="base64",
            data=pdf,
            media_type="application/pdf",
            filename="contract.pdf",
        )
    ],
)

source_type="url" accepts http/https only — file:// and friends are rejected, so an attachment cannot be turned into a server-side file read. Text extraction dependencies are optional extras; a missing one raises a typed error naming the extra rather than failing silently. See Guide 07 — Attachment Extraction.

Lifecycle

close() is optional, idempotent, terminal, and never raises. In this release the facade owns no resources, so it only marks the instance closed; it never closes the provider you passed in, because the provider outlives the agent. An in-flight run() is allowed to finish — close() does not cancel it.

async with Agent(provider) as agent:      # optional, not required
    await agent.run("hello")

Using a closed agent raises ContractViolationError. There is no reopen: construct a new one.

What this rung does not have

Sessions, HMS memory, tenant scope, checkpoints and resume, sub-agents, human-approval pauses, plugins, MCP, the FastAPI router, and the 26-hook callback surface. None of it was removed and none of it is deprecated — it lives one rung up, and Guide 22 is the map.

Where to go next

You want Go to
tools, prompts, memory, delegation as first-class capabilities Capabilities and Guide 22 §Capabilities
sessions, checkpoints, budgets, consolidation Conversation Persistence
tenants, HTTP, auth, privacy, billing Guide 05 — Production
providers, backends, MCP, plugins, OpenTelemetry Model Providers, Guide 04 — Plugins & Domains
the kernel, the event stream, the contracts Architecture
everything you already use, and where it is now Feature Preservation Reference