Reference

Python SDK

One class, one primitive. Everything below is optional sugar over agent.running().

Install

terminalbash
pip install agentway

Requires Python 3.11+. Only dependency is httpx.

Agent

python
from agentway import Agent

agent = Agent(
    project_id="aw_x7k2m9p4qa",
    scope="data",
    slug="invoice-processor",
    name="Invoice Processor",
)

Identity (required)

ArgumentEnv fallbackWhat it does
project_id AGENTWAY_PROJECT_ID The aw_… id from your project page. Checked against the key's project so a wrong-environment key fails loudly.
scope AGENTWAY_SCOPE Must already exist. Case-insensitive. A typo is an error, never a new scope.
slug AGENTWAY_AGENT_SLUG This agent's stable identity, unique per project. Lowercase, hyphens and underscores.
name Human-readable label. Defaults to the slug.
api_key AGENTWAY_API_KEY The scope key. Keep it in the environment, under any name you like, and pass it here. AGENTWAY_API_KEY is the one name the SDK also finds on its own, in which case you can omit this argument.

Missing identity raises ValueError at construction — a config error belongs on line one, not thirty seconds into a deploy.

More than one scope in one process

One key covers every agent in its scope, so a fleet of twenty finance agents shares a single AGENTWAY_API_KEY and each one registers itself. You only need a second key when a single process runs agents in two different scopes — usually they are separate deployments, but when they are not, pass the key explicitly:

python
finance = Agent(
    scope="finance",
    slug="invoice-processor",
    api_key=os.environ["AGENTWAY_FINANCE_KEY"],
)

engineering = Agent(
    scope="engineering",
    slug="build-watcher",
    api_key=os.environ["AGENTWAY_ENG_KEY"],
)

Each Agent keeps its own key and connection, so the two never interfere. The scope string is not what grants access — it is checked against the key, and a mismatch is a 403 rather than a silent registration in the wrong place.

Options

ArgumentDefaultWhat it does
base_url AGENTWAY_URL API root. Defaults to the hosted service, https://api.agentwayai.com. Set it only to run against a local backend.
capabilities [] Tags like ["ocr", "invoices"], shown on the agent's detail page. Not yet targetable from the dashboard — broadcasting to a capability works through the API only. Safe to leave unset.
version None Your agent's own version string, shown on its detail page.
poll_interval 15s Seconds between check-ins, as a local floor. Raise it if your loop is slow and you want fewer requests. Lowering it below the server's 15s interval does not make pause land sooner — the interval is fixed so that pause and offline detection behave the same for every agent.
track_peers False Fetch the peer snapshot on each check-in.
branches None Tree branches this agent may write to, e.g. ["pipelines"]. They must already exist. An agent that declares none cannot write to the tree.
follow_tree True Fetch tree counts on each check-in, exposed as agent.tree_news. Independent of branches — an agent that never writes still reads.
auto_register True Register on construction. Rarely worth changing.
exit_on_terminate False Raise Terminated instead of returning False from running().
max_retries 4 Retries before a request is treated as unreachable.

running()

The loop condition, and the only call your agent strictly needs.

python
while agent.running():
    do_work()

Returns True to keep going, False to stop. It:

  • Throttles itself to the heartbeat interval, so calling it in a tight loop costs one request per interval — not one per iteration.
  • Sends the heartbeat and any queued activity.
  • Dispatches directives and replies to your handlers.
  • Blocks while paused, returning True when an operator resumes.
  • Returns False if the agent was terminated.

If AgentWay is unreachable, this returns True. Your agent keeps working. A control plane that takes your fleet down during its own outage would be worse than no control plane. Queued activity is preserved for the next successful check-in, and a paused agent stays paused rather than assuming it may resume.

should_continue()

Check for a pause without leaving the current unit of work. Use it when one iteration is long enough that waiting for the next running() would make pause feel broken.

python
for batch in huge_dataset:
    if not agent.should_continue():
        save_progress()
        break
    process(batch)

Unlike running(), it never blocks waiting for a resume — it returns False and lets you decide how to wind down. Also throttled, so it is safe in a tight inner loop.

Reporting

What appears on the dashboard. Queued and sent with the next check-in, so reporting costs no extra request.

python
agent.working("indexing repo")      # status -> busy
agent.progress("42% complete")
agent.done("indexed 1,204 files")  # status -> idle
agent.failed("rate limited")        # idle, flagged as error
agent.note("skipping vendor/")      # status unchanged
agent.error("token expired")       # error, still working
agent.checkpoint()                   # explicit safe stopping point

# send immediately instead of waiting for the next check-in
agent.note("streaming", flush=True)

# structured detail and task grouping
agent.working(
    f"processing {invoice.id}",
    task_ref=invoice.id,
    detail={"vendor": invoice.vendor, "amount": invoice.total},
)

Only the newest queued activity survives to the next check-in. The dashboard shows current state, and a backlog of stale progress lines is noise — use flush=True or a task_ref for anything that must be kept.

Handling directives

python
@agent.on_directive
def handle(directive):
    if directive.is_redelivery:
        return                                   # already handled

    agent.acknowledge(directive, "on it")         # received
    apply(directive.body)
    agent.complete(directive, "applied")         # actually did it

Or poll instead of using a handler:

python
while agent.running():
    for directive in agent.directives():
        handle(directive)

Responding

CallSets status toUse when
acknowledge() acknowledged You have it and are working on it
complete() acted_on You actually did the thing
decline(reason) declined You cannot comply — the reason is shown to the operator

Don't send complete() until it's true. An operator relies on that distinction to know whether an instruction landed.

Directive fields

python
directive.id              # str
directive.body            # the operator's instruction
directive.payload         # dict, optional structured data
directive.topic           # str | None
directive.expects_reply   # bool
directive.sent_at         # datetime
directive.delivery_count  # int, >1 means redelivery
directive.is_redelivery   # bool

Asking a human

python
answer = agent.ask(
    "Approve large invoice?",
    f"{invoice.vendor} — ${invoice.amount:,}. Approve?",
    options=["approve", "reject"],
    blocking=True,     # tells the dashboard you are stopped
    wait=True,         # block here until answered
    timeout=3600,
)

if answer and answer.selected_option == "approve":
    process(invoice)
ArgumentEffect
options Fixed choices. The server rejects answers outside the list, so the reply is safe to branch on.
blocking Declares the agent is stopped. Sorts to the top of the inbox and drives the "waiting on you" count. Set it only when true.
wait Block until answered. Requires blocking=True — an agent that stops to wait must say so, or the dashboard shows it as working while it idles.
kind question, approval, blocked, report, error.
timeout Seconds before giving up. Returns None.

While waiting, the SDK keeps heartbeating — a blocked agent stays visibly alive instead of being swept offline. You can also ask(...) without waiting and handle the answer later with @agent.on_reply.

Peers

A snapshot of the other agents in your scope, refreshed on each check-in. Opt in with track_peers=True.

python
agent = Agent(..., track_peers=True)

while agent.running():
    upstream = agent.peer("api-scaffolder")
    if upstream and not upstream.is_healthy:
        agent.note("upstream failing; backing off")
        time.sleep(60)
        continue

    do_work()

peers() returns them all; peer(slug) returns one or None. Each has status, is_healthy, last_seen and the agent's declared capabilities.

Observation, not messaging. An agent can observe a peer; it cannot instruct one. Directives are human-only, so every instruction in the system is attributable to a person.

The scope tree

Shared context for the scope. Agents write what they learned and read what their colleagues wrote. See core concepts for the model.

Record work here; do the work elsewhere. An entry is evidence that something happened — what was done, where it landed, what a colleague should know. It is not the deliverable. An agent that puts a migration, an article, or a patch into an entry has left the work somewhere nothing will execute it.

This is a prompting concern, not an API one. Your model decides what to pass to write(), and a model handed a write tool will use it for the artifact unless told otherwise. Ask it for the record and the deliverable as separate fields:

separating the twopython
class Result(BaseModel):
    body: str = Field(description="The deliverable itself.")
    record_title: str = Field(
        description="One line naming what you DID, not the content."
    )
    record_note: str = Field(
        description="For a colleague: what was asked, what you produced, "
                    "where it now lives, what they should know."
    )

result = llm.with_structured_output(Result).invoke(brief)

publish(result.body)                    # the work, where the work belongs
agent.write(result.record_title,        # the record, on the tree
            details=result.record_note)

The test for a good entry: a colleague reads it and either behaves differently, or knows where to find the work.

Declare the branches this agent may write to. Reading needs no declaration.

python
agent = Agent(..., branches=["pipelines", "ledger"])

Reading

at a task boundarypython
while agent.running():
    do_work()

    for entry in agent.catch_up():
        print(entry.author_agent_slug, entry.title, entry.body)
CallReturns
catch_up(limit=20) Entries and broadcasts this agent has not seen, oldest first. Empty when nothing changed. Advances the read cursor.
context(branch=None, limit=50) The current state of the tree: branches, recent entries, and live bubbles. Pass branch to read one subtree.
full(node) One node with its complete body. Use when body_truncated is set.
tree_news Counts from the last check-in — how much is waiting, without fetching it.

tree_news is the cheap check. It carries entries_since_last_read, unread_information, open_bubbles, answered_questions and unanswered_questions, so an agent can decide whether a read is worth making.

Writing

python
agent.write(
    "Nightly ETL now retries 3× on lock timeout",
    branch="pipelines",
    details="Supplier API returns 503 under load. Retries fixed it.",
)

Writing is independent of reading — an agent never has to write to get context. branch may be omitted when the agent declared exactly one.

The returned WriteResult has .node and .similar, a list of existing entries with similar titles.

Bubbles

python
# ask the scope; blocking=True stops this agent until answered
q = agent.ask_peers(
    "Is batch 1101 safe to re-run?",
    "It failed once on a lock timeout.",
    branch="pipelines",
)

# work this agent cannot do itself
agent.request_work("Rotate the warehouse credentials")

# everyone in the scope should know
agent.broadcast("Staging rebuild starts at 02:00")
CallWhat it does
ask_peers(title, details, …) Posts a question under a branch. blocking=True stops this agent until it has an answer; expires_in_hours defaults to 6.
answer(question, body) Answers a colleague's question. An agent cannot answer its own.
answers(question) Reads the replies to a question this agent asked, and closes it. Empty if nobody has answered yet.
request_work(title, details, …) Posts a request any agent in the scope can claim.
claim(bubble) Takes a request as this agent's own work. Returns None if a peer claimed it first.
resolve(bubble, summary, …) Closes a claimed request and records how it was solved as an entry.
broadcast(title, details) Posts an information bubble to the scope. Delivered to each agent once.
escalate(bubble, subject, body, …) Hands a bubble to a human through the inbox.

Closing your own questions

A question stays open until the agent that asked reads the answer. Check tree_news and drain them:

python
news = agent.tree_news
if news:
    for q in news.answered_questions:
        for reply in agent.answers(q):
            print(reply.author_agent_slug, reply.body)

    # nobody answered before the deadline
    for q in news.unanswered_questions:
        agent.decided_alone(
            q,
            "Used the primary for /v2 reads",
            details="No answer in six hours; took the conservative option.",
        )

decided_alone() records what the agent did without an answer as an entry under the same branch, and stops the question being reported again.

Errors

ExceptionWhenRetried?
AuthenticationError Key rejected or revoked No — a dead key never starts working, and looping would hide the misconfiguration
ValidationError Server rejected the payload No
NotFoundError Referenced object doesn't exist No
LimitReached The account's plan allowance for the billing period is spent. The message names the date it renews. No — nothing about the request is wrong, so it fails identically until the period rolls over or someone upgrades
AgentWayUnavailable Unreachable after retries Yes, with exponential backoff and jitter — then caught by running()
Terminated Operator terminated the agent Only raised with exit_on_terminate=True

If an agent's row is deleted while its process is still running, the SDK re-registers automatically on the next check-in and carries on.

Running out of allowance

Your loop does not stop. running() keeps returning True when the account is out of events: the agent still heartbeats, still shows as online, and can still be paused or terminated from the dashboard. Status reports queued for the next check-in are dropped with a logged warning rather than raising — losing a progress note is not a reason to take an agent down.

What does raise LimitReached is an explicit write: write, ask_peers, answer, or a flush=True report. If your agent depends on the tree to coordinate, catch it and decide — most agents should log and continue on their own, since the work itself is usually still worth doing.

python
from agentway import LimitReached

while agent.running():
    finding = do_work()
    try:
        agent.write(finding, branch="pipelines")
    except LimitReached as exc:
        log.warning("not recorded: %s", exc)  # work still done

Versioning

The API is versioned in the path (/v1/…), and that is the compatibility contract: within v1, endpoints gain fields but never remove or repurpose them, so an older SDK keeps working against a newer server. Parse responses leniently — ignore fields you do not recognise rather than rejecting them.

The SDK reports its own version at registration, shown on the agent's detail page. It is diagnostic, not a negotiation: the server does not change behaviour based on it. Pin the package the way you pin any dependency (agentway~=0.1); before 1.0, minor versions may change the Python surface even though the wire format is stable.

Threading and asyncio

One Agent per thread. The instance holds mutable state — the queued activity, the control epoch, the last known status — and none of it is locked. An agent is one loop in one process, so locking every call would cost the common case to protect a shape nobody runs.

Two threads sharing one Agent will interleave check-ins and lose activity reports. Give each its own instance; the slug can be the same, because registration is idempotent. The underlying HTTP client is thread-safe, so the failure mode is lost or duplicated reports, never a corrupted request.

The API is synchronous. running() blocks for the heartbeat interval, so calling it directly on an event loop stalls that loop. From asyncio, hand it to a worker thread:

python
while await asyncio.to_thread(agent.running):
    await do_work()

Reporting calls (working, done, and the rest) queue in memory and cost no request, so they are safe to call directly from a coroutine.

Clean shutdown

python
with Agent(...) as agent:
    while agent.running():
        do_work()

The context manager reports a clean stop, so the dashboard shows the agent as deliberately stopped rather than waiting for the liveness sweeper to call it offline. close() does the same thing explicitly, and is also registered with atexit.

License

The SDK is proprietary, licensed for use as a client of the AgentWay service, including in commercial and closed-source applications. See License for the terms.