AI agents in your app
pyzed.agents is the engine behind the designer’s Agent panel, and it works in any
Pyzed app: one API for several model providers, tools written as plain Python functions, streaming events for the
UI, saved threads, and keys kept in the operating system’s keychain.
uv add "pyzed-ui[agents]" # anthropic (Claude) and keyringpip install "pyzed-ui[agents]" # anthropic (Claude) and keyringpython3 -m pip install "pyzed-ui[agents]" # anthropic (Claude) and keyringpy -m pip install "pyzed-ui[agents]" # anthropic (Claude) and keyringA first agent
Section titled “A first agent”from pyzed import agents
agents.set_key("anthropic", "sk-ant-…") # once: saved in the system keychain
@agents.tooldef stock(product: str) -> int: """How many units of a product are in stock.
product: the product's name. """ return {"chair": 4, "desk": 0}.get(product, 0)
agent = agents.Agent("anthropic:claude-opus-5-5", tools=[stock], system="You help the shop's staff.")print(agent.run("Can I sell two desks today?"))run calls the model, runs the tools it asks for, sends their results back and repeats until the model answers.
It returns the last answer and never raises: failures (a bad key, no network) come back as an "error" event and
as the returned text. agent.messages keeps the conversation, so the next run continues it.
Models and providers
Section titled “Models and providers”A model is named "<provider>:<model>":
| Provider | Example | Credentials |
|---|---|---|
anthropic |
anthropic:claude-opus-5-5 |
set_key, ANTHROPIC_API_KEY (an ant auth login profile also works), or identity federation |
openai |
openai:<model> |
set_key or OPENAI_API_KEY |
google |
google:<gemini model> |
set_key or GEMINI_API_KEY |
openrouter |
openrouter:anthropic/claude-opus-5-5 |
set_key, OPENROUTER_API_KEY or agents.login.openrouter_login() |
xai, mistral, deepseek, groq |
groq:<model> |
set_key or XAI_API_KEY, MISTRAL_API_KEY, DEEPSEEK_API_KEY, GROQ_API_KEY |
github |
github:openai/gpt-4.1 |
GitHub Models: agents.login.github_device_code() (device flow), a fine-grained token with the Models permission or GITHUB_TOKEN |
vertex |
vertex:google/gemini-2.5-pro |
Gemini on Vertex AI: agents.login.google_login() or the gcloud credentials of the computer; settings.provider("vertex").project |
ollama, lmstudio |
ollama:qwen3:8b |
none (local) |
custom |
custom:<model> |
the server URL in the settings, and a key if needed |
agents.list_models("openai") asks a provider for its models. Claude goes through the official anthropic SDK
with adaptive thinking, the effort setting, prompt caching and the server-side refusal fallback; every other
provider through the OpenAI-compatible Chat Completions API.
Settings
Section titled “Settings”settings = agents.AgentSettings(model="ollama:qwen3:8b", effort="medium", profile="ask", rules="Answer in Spanish.")settings.provider("ollama").base_url = "http://gpu-box:11434/v1"agent = agents.Agent(tools=[stock], settings=settings)| Field | Meaning |
|---|---|
model |
Default model. |
effort |
low, medium, high, xhigh or max (Claude). |
fast |
Fast mode on the models in providers.FAST_MODELS (Claude Opus 5.5 and Opus 5, Claude API only): faster output at a higher price. |
profile |
"write" (every tool), "ask" (only tools without writes=True) or "minimal" (no tools). |
mode |
The Agent panel’s permission mode, a shortcut over profile and confirm_writes: "ask", "bypass", "read" or "chat". |
confirm_writes |
Call confirm(tool, args) before a tool marked writes=True (on by default). Without a confirm callback those tools are refused; turn it off to let them run unasked. |
rules |
Text added to the system prompt. |
keep_history |
Whether Pyzed Design saves threads on this computer (on by default). |
providers[id].base_url / .models |
Server URL and the models offered for a provider. |
agents.providers.load() and save() read and write agents.json in Pyzed’s settings folder (the designer’s
settings); keys are never part of it.
@agents.tool builds the JSON schema from the signature: str, int, float, bool, list, dict and
Optional[...]; arguments with a default are optional. The first paragraph of the docstring describes the tool
and name: text lines describe the arguments. Return a string or anything JSON can encode.
@agents.tool(writes=True, title="Placing an order")def order(product: str, units: int) -> dict: """Orders units of a product from the supplier.""" ...| Option | Meaning |
|---|---|
writes=True |
It changes something: skipped in the "ask" profile, and confirmed first when confirm_writes is on. |
ui=True |
Runs through the agent’s executor (see below). |
title |
Short text for your UI while it runs. |
The model’s arguments are checked against the schema before the function runs; a wrong or missing argument, or an exception, goes back to the model as an error so it can fix its call.
Ready-made tools: agents.pyzed_tools() (Pyzed’s components, icons, themes, docs, generators and checks, the
same as the MCP server) and agents.file_tools(folder) (list, read, search and write files,
never outside folder). The file tools never open keys or credentials (.env, *.pem, *.key, SSH keys…;
agents.tools.SECRET_FILES), so a prompt injection in a project file cannot send them to the provider; pass
hidden= to change the list.
Images
Section titled “Images”Models that take images can look at them in a message, and tools can return them:
image = agents.Image.open("mockup.png") # PNG, JPEG, GIF or WebP, up to agents.agent.IMAGE_LIMITagent.run([{"type": "text", "text": "Build this screen."}, agents.image_block(image)])
@agents.tooldef chart() -> list: """Draws the sales chart.""" return ["The chart:", agents.Image(render_png())] # text and images go back to the modelimage_block also takes the bytes or a path. A tool that returns an Image, or a list with text and images,
answers with them; providers without images in tool results get them in the next message.
MCP servers
Section titled “MCP servers”agents.mcp_client connects to Model Context Protocol servers, local (stdio)
or remote (Streamable HTTP), and turns their tools into agent tools:
from pyzed.agents import mcp_client, providers
fetch = providers.MCPServerConfig("fetch", "uvx", ["mcp-server-fetch"])github = providers.MCPServerConfig("github", url="https://api.githubcopilot.com/mcp/", secret_names=["Authorization"])mcp_client.set_secrets("github", {"Authorization": "Bearer …"}) # in the keychain
servers = mcp_client.Servers([fetch, github], cwd=".")agent = agents.Agent("anthropic:claude-opus-5-5", tools=servers.tools())...servers.close()Tools are named mcp_<server>_<tool> and the ones the server doesn’t mark readOnlyHint are writes=True, so
they are confirmed first. secret_names lists the environment variables (stdio) or headers (HTTP) whose values
set_secrets keeps in the keychain. A server that fails is skipped; pass on_error(server, message) to
tools() to hear about it. mcp_client.MCPClient talks to one server directly (list_tools, call).
External agents
Section titled “External agents”agents.acp runs agents that speak the Agent Client Protocol (Claude Code,
Gemini CLI, Codex…) as their own program and streams their work as the same events as Agent:
from pyzed.agents import acp
config = acp.preset("claude-code") # or "gemini", "codex", or providers.ACPAgentConfig(name, command, args)own = acp.pyzed_mcp_server() # Pyzed's MCP server, or None without the mcp packageagent = acp.ACPAgent(config, cwd="my_app", confirm=lambda tool, args: True, mcp_servers=[own] if own else [])print(agent.run("Add a dark theme to the app", on_event=print))agent.close()The agent reads and writes files through Pyzed, only inside cwd and never SECRET_FILES. Its permission
requests follow settings.mode: "bypass" allows them, "read" allows reads only, "chat" refuses them and
"ask" calls confirm. acp.installed(config) tells whether its program is on the PATH, and the settings
keep them in AgentSettings.acp_agents (and MCP servers in mcp_servers; acp.mcp_entries(settings.mcp_servers)
hands those to the agent too).
Your own tools can go to the external agent too: mcp_host.ToolServer serves an Agent’s tools over MCP on
127.0.0.1 (a one-time address and a bearer token), which is how Pyzed Design lets Claude Code control the editor:
from pyzed.agents import mcp_host
server = mcp_host.ToolServer(agents.Agent(tools=[my_tool]), name="my_app", instructions="How to use them")agent = acp.ACPAgent(config, cwd="my_app", mcp_servers=[server.acp_entry(), server.bridge_entry()])...server.close()Given both, the agent gets the HTTP entry when it supports it and the stdio bridge otherwise.
acp.claude_signed_in() tells whether Claude Code is signed in on this computer, and acp.claude_login_command()
is the command that opens it to sign in with a Claude subscription.
Streaming into the UI
Section titled “Streaming into the UI”run blocks, so in an app call it from a worker thread and hand the events to the UI. Pyzed’s views run on the UI
thread, so put events on a queue and drain it from a pz.every subscription:
import queue, threadingimport pyzed as pzfrom pyzed import agents, ui
class Chat(pz.App): def __init__(self): self.agent = agents.Agent("anthropic:claude-opus-5-5") self.events = queue.Queue() self.answer, self.prompt, self.busy = "", "", False
def send(self): self.busy, self.answer = True, "" threading.Thread(target=self.agent.run, args=(self.prompt,), kwargs={"on_event": self.events.put}, daemon=True).start()
def poll(self): while not self.events.empty(): event = self.events.get() if event.kind == "text": self.answer += event.text elif event.kind == "done": self.busy = False
def subscriptions(self): return [pz.every(1 / 30, self.poll, key="chat")] if self.busy else []
def view(self): return pz.column([ ui.markdown(self.answer), ui.input(self.prompt, lambda v: setattr(self, "prompt", v), on_submit=self.send), ], spacing=8, padding=16)Events: text and thinking (a new piece of the answer or of the reasoning summary), tool_call and
tool_result (tool, args, call_id, result, error), turn (one model answer finished, with usage),
error and done. agent.stop() cancels from any thread.
Tools that must touch your app’s state should run on the UI thread too: pass executor= (a function that runs a
callable there and returns its result) and mark them ui=True. The designer does exactly that, which is why the
agent’s edits are ordinary undo steps.
Keys and sign-in
Section titled “Keys and sign-in”agents.set_key("openai", "sk-…") # True: in the keychain; False: no keychain, kept for this session onlyagents.get_key("openai") # the saved key, or the environment variableagents.delete_key("openai")agents.secrets.store().backend_name # "Keyring" backend in use, or "memory (this session only)"
key = agents.login.openrouter_login() # OpenRouter: browser + PKCE, returns a keyagents.set_key("openrouter", key)
agents.login.key_page("anthropic") # the others: opens the provider's API keys page...agents.providers.looks_like_key("anthropic", copied_text) # ...and tells whether a copied text is its key
def check(key): # why the provider refuses the key, or None try: agents.list_models("anthropic", key=key) except agents.AgentError as err: return str(err)
# ...or a sign-in page served on 127.0.0.1: it links to the keys page and returns the key pasted therekey = agents.login.key_portal("anthropic", check)GitHub and Google sign in without a key too, with OAuth apps you register (their IDs go in the provider’s
settings, or PYZED_GITHUB_CLIENT_ID / PYZED_GOOGLE_CLIENT_ID):
code = agents.login.github_device_code(client_id) # show code.user_code and open code.verification_uritoken = agents.login.github_device_token(client_id, code) # waits until it is approvedagents.providers.save_token("github", token) # in the keychain; refreshed when it expires
token = agents.login.google_login(client_id, client_secret) # browser + PKCE, scope cloud-platformagents.providers.save_token("vertex", token)Without signing in, vertex uses the gcloud credentials of the computer (gcloud auth application-default login) and github takes a fine-grained token with the Models permission as its key.
Besides these, only OpenRouter offers OAuth to other apps. For the rest, Pyzed Design opens the key page and watches the
clipboard for a few minutes; when a text that looks_like_key is copied, it checks it with list_models(key=...)
and saves it with set_key. Your app can do the same.
Keys live in the system keychain (keyring, service pyzed); PYZED_KEYRING=0 disables it, and
agents.secrets.use(SecretStore(my_backend)) plugs in your own vault (any object with keyring’s
get_password / set_password / delete_password). The lookup order, the environment variable of each provider
and how to keep keys safe are in API keys and credentials.
Claude without an API key
Section titled “Claude without an API key”With workload identity federation, a token from your identity provider (GitHub Actions, Google Cloud, Azure, Okta, Kubernetes…) is exchanged for a short-lived Claude token, so no API key is stored. Create the issuer and the rule in the Claude Console, then:
settings = agents.AgentSettings()settings.federation = agents.providers.FederationSettings( rule_id="fdrl_…", organization_id="…", service_account_id="svac_…", token_source="command", token_command="gcloud auth print-identity-token")agent = agents.Agent("anthropic:claude-opus-5-5", settings=settings)token_source is "file" (token_file, or ANTHROPIC_IDENTITY_TOKEN_FILE), "command" (prints a fresh token)
or "env" (token_env, ANTHROPIC_IDENTITY_TOKEN by default). A saved key wins over federation.
providers.credential_source("anthropic", settings=settings) tells where the credentials come from.
Threads
Section titled “Threads”store = agents.ThreadStore() # agents/threads in Pyzed's settings folderthread = agents.Thread(model="ollama:qwen3:8b")agent = agents.Agent(thread.model, messages=thread.messages)agent.run("Hello")store.save(thread) # JSON only you can read; no keysstore.list() # newest firstTesting
Section titled “Testing”Pass stream= to Agent to replace the model with a script, so tests run offline and always the same way:
from pyzed.agents import llm
def fake(model, system, messages, tools, *, settings, on_event, cancel): if len(messages) == 1: return llm.Turn([{"type": "tool_use", "id": "1", "name": "stock", "input": {"product": "desk"}}], "tool_use") return llm.Turn([{"type": "text", "text": "No desks left."}])
agent = agents.Agent("x", tools=[stock], stream=fake, settings=agents.AgentSettings())assert agent.run("Desks?") == "No desks left."A complete chat app
Section titled “A complete chat app”Building AI apps builds a full streaming chat with an OpenAI-compatible API, stop, errors and
tests without network, and shows how to drive pyzed.agents from pz.Command.perform and pz.post.