zonrad-sdk 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
zonrad/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .client import Zonrad
2
+ from .exceptions import ZonradAPIError, ZonradError, ZonradToolNotRegisteredError
3
+
4
+ __all__ = ["Zonrad", "ZonradAPIError", "ZonradError", "ZonradToolNotRegisteredError"]
zonrad/_http.py ADDED
@@ -0,0 +1,23 @@
1
+ import requests
2
+
3
+ from .exceptions import ZonradAPIError
4
+
5
+
6
+ class HTTPClient:
7
+ def __init__(self, base_url: str, api_key: str, timeout: float = 30):
8
+ self.base_url = base_url.rstrip("/")
9
+ self.timeout = timeout
10
+ self.session = requests.Session()
11
+ self.session.headers["Authorization"] = f"Bearer {api_key}"
12
+
13
+ def request(self, method: str, path: str, **kwargs):
14
+ resp = self.session.request(method, f"{self.base_url}{path}", timeout=self.timeout, **kwargs)
15
+ if not resp.ok:
16
+ raise ZonradAPIError.from_response(resp)
17
+ return resp.json() if resp.content else None
18
+
19
+ def stream(self, method: str, path: str, **kwargs):
20
+ resp = self.session.request(method, f"{self.base_url}{path}", stream=True, timeout=None, **kwargs)
21
+ if not resp.ok:
22
+ raise ZonradAPIError.from_response(resp)
23
+ return resp
zonrad/_schema.py ADDED
@@ -0,0 +1,62 @@
1
+ """
2
+ Ported from apps.custom_agents.tool_registry's build_schema_from_function /
3
+ _python_type_to_json_schema (backend_django) — same conversion rules, so a
4
+ function looks identical to the model whether registered server-side via
5
+ @agent_tool_registry.register_tool(...) or client-side via @z.tools.local(...).
6
+ """
7
+
8
+ import inspect
9
+ import typing
10
+
11
+
12
+ class ToolSchemaError(Exception):
13
+ pass
14
+
15
+
16
+ def _python_type_to_json_schema(annotation):
17
+ origin = typing.get_origin(annotation)
18
+
19
+ if origin is typing.Union:
20
+ args = [a for a in typing.get_args(annotation) if a is not type(None)]
21
+ if len(args) == 1:
22
+ return _python_type_to_json_schema(args[0]) # Optional[X]
23
+ raise ToolSchemaError(f"Unsupported Union type: {annotation!r}. Only Optional[X] is supported.")
24
+
25
+ if origin in (list, typing.List):
26
+ item_args = typing.get_args(annotation)
27
+ item_schema = _python_type_to_json_schema(item_args[0]) if item_args else {"type": "string"}
28
+ return {"type": "array", "items": item_schema}
29
+
30
+ if origin in (dict, typing.Dict) or annotation is dict:
31
+ return {"type": "object"}
32
+
33
+ simple = {str: "string", int: "integer", float: "number", bool: "boolean"}
34
+ if annotation in simple:
35
+ return {"type": simple[annotation]}
36
+ if annotation is list:
37
+ return {"type": "array", "items": {"type": "string"}}
38
+
39
+ raise ToolSchemaError(f"Unsupported parameter type: {annotation!r}. Supports str, int, float, bool, list, dict, and Optional[...] of these.")
40
+
41
+
42
+ def _is_optional(annotation):
43
+ return typing.get_origin(annotation) is typing.Union and type(None) in typing.get_args(annotation)
44
+
45
+
46
+ def build_schema_from_function(fn):
47
+ """Derives an OpenAI-compliant JSON schema from a function's type hints."""
48
+ sig = inspect.signature(fn)
49
+ properties = {}
50
+ required = []
51
+ for name, param in sig.parameters.items():
52
+ if param.annotation is inspect.Parameter.empty:
53
+ raise ToolSchemaError(
54
+ f"Tool function '{fn.__name__}' parameter '{name}' has no type hint — "
55
+ f"all parameters must be annotated for schema derivation."
56
+ )
57
+ schema = _python_type_to_json_schema(param.annotation)
58
+ schema["description"] = f"Parameter '{name}'"
59
+ properties[name] = schema
60
+ if param.default is inspect.Parameter.empty and not _is_optional(param.annotation):
61
+ required.append(name)
62
+ return {"type": "object", "properties": properties, "required": required}
zonrad/_sse.py ADDED
@@ -0,0 +1,18 @@
1
+ import json
2
+
3
+
4
+ def iter_sse_events(response):
5
+ """Parses a streaming requests.Response as SSE.
6
+
7
+ Matches the server's exact wire format (apps.custom_agents.runtime.sse_format
8
+ in backend_django): one single-line `data: {...}` per event, with a blank
9
+ line between events — the server never emits multi-line SSE data blocks,
10
+ so this doesn't need a general-purpose SSE parser, just line splitting.
11
+ """
12
+ for raw_line in response.iter_lines(decode_unicode=True):
13
+ if not raw_line or not raw_line.startswith("data: "):
14
+ continue
15
+ try:
16
+ yield json.loads(raw_line[len("data: "):])
17
+ except json.JSONDecodeError:
18
+ continue
zonrad/client.py ADDED
@@ -0,0 +1,32 @@
1
+ from ._http import HTTPClient
2
+ from .resources.agents import AgentsResource
3
+ from .resources.api_keys import ApiKeysResource
4
+ from .resources.connections import ConnectionsResource
5
+ from .resources.models import ModelsResource
6
+ from .resources.policies import PoliciesResource
7
+ from .resources.runs import RunsResource
8
+ from .resources.sessions import SessionsResource
9
+ from .resources.tools import ToolkitsResource, ToolsResource
10
+
11
+
12
+ class Zonrad:
13
+ """
14
+ workspace_id is captured once here, not passed per-call — a
15
+ PlatformAPIKey is already scoped to exactly one workspace server-side
16
+ (docs/PLATFORM_PHASE_5_SPEC.md §4.3), so requiring it again on every
17
+ resource call would be redundant. Every key-minting response
18
+ (z.api_keys.create()) includes the workspace_id to pair with it.
19
+ """
20
+
21
+ def __init__(self, api_key: str, workspace_id, base_url: str = "http://localhost:8000/platform/v1"):
22
+ self._http = HTTPClient(base_url, api_key)
23
+ self.workspace_id = workspace_id
24
+ self.agents = AgentsResource(self)
25
+ self.sessions = SessionsResource(self)
26
+ self.runs = RunsResource(self)
27
+ self.tools = ToolsResource(self)
28
+ self.toolkits = ToolkitsResource(self)
29
+ self.connections = ConnectionsResource(self)
30
+ self.models = ModelsResource(self)
31
+ self.policies = PoliciesResource(self)
32
+ self.api_keys = ApiKeysResource(self)
zonrad/exceptions.py ADDED
@@ -0,0 +1,34 @@
1
+ class ZonradError(Exception):
2
+ """Base class for all errors raised by this SDK."""
3
+
4
+
5
+ class ZonradAPIError(ZonradError):
6
+ """Raised for any non-2xx response from the Zonrad platform API."""
7
+
8
+ def __init__(self, status_code, message, error_code=None, body=None):
9
+ self.status_code = status_code
10
+ self.error_code = error_code
11
+ self.body = body
12
+ super().__init__(f"[{status_code}] {message}")
13
+
14
+ @classmethod
15
+ def from_response(cls, resp):
16
+ try:
17
+ body = resp.json()
18
+ except ValueError:
19
+ body = {"message": resp.text}
20
+ message = body.get("message") or body.get("error") or "Request failed."
21
+ error_code = body.get("error_code")
22
+ return cls(resp.status_code, message, error_code=error_code, body=body)
23
+
24
+
25
+ class ZonradToolNotRegisteredError(ZonradError):
26
+ """Raised when the server asks the SDK to execute a client tool
27
+ (docs/PLATFORM_PHASE_6_SPEC.md) that has no @z.tools.local(...) handler
28
+ registered in this process."""
29
+
30
+ def __init__(self, name):
31
+ self.name = name
32
+ super().__init__(
33
+ f"Server requested client tool '{name}', but no @z.tools.local(...) handler is registered for it."
34
+ )
File without changes
@@ -0,0 +1,20 @@
1
+ class AgentsResource:
2
+ """Plain dict in, plain dict out — CRUD has no behavior worth a wrapper object."""
3
+
4
+ def __init__(self, client):
5
+ self._c = client
6
+
7
+ def list(self):
8
+ return self._c._http.request("GET", f"/workspaces/{self._c.workspace_id}/agents")
9
+
10
+ def create(self, **fields):
11
+ return self._c._http.request("POST", f"/workspaces/{self._c.workspace_id}/agents", json=fields)
12
+
13
+ def get(self, agent_id):
14
+ return self._c._http.request("GET", f"/workspaces/{self._c.workspace_id}/agents/{agent_id}")
15
+
16
+ def update(self, agent_id, **fields):
17
+ return self._c._http.request("PATCH", f"/workspaces/{self._c.workspace_id}/agents/{agent_id}", json=fields)
18
+
19
+ def delete(self, agent_id):
20
+ return self._c._http.request("DELETE", f"/workspaces/{self._c.workspace_id}/agents/{agent_id}")
@@ -0,0 +1,15 @@
1
+ class ApiKeysResource:
2
+ """Note: the `key` field in create()'s response is shown exactly once —
3
+ it is never retrievable again after this call returns."""
4
+
5
+ def __init__(self, client):
6
+ self._c = client
7
+
8
+ def list(self):
9
+ return self._c._http.request("GET", f"/workspaces/{self._c.workspace_id}/api-keys")
10
+
11
+ def create(self, name: str = ""):
12
+ return self._c._http.request("POST", f"/workspaces/{self._c.workspace_id}/api-keys", json={"name": name})
13
+
14
+ def revoke(self, key_id):
15
+ return self._c._http.request("DELETE", f"/workspaces/{self._c.workspace_id}/api-keys/{key_id}")
@@ -0,0 +1,6 @@
1
+ class ConnectionsResource:
2
+ def __init__(self, client):
3
+ self._c = client
4
+
5
+ def list(self):
6
+ return self._c._http.request("GET", f"/workspaces/{self._c.workspace_id}/connections")["connections"]
@@ -0,0 +1,6 @@
1
+ class ModelsResource:
2
+ def __init__(self, client):
3
+ self._c = client
4
+
5
+ def list(self):
6
+ return self._c._http.request("GET", f"/workspaces/{self._c.workspace_id}/models")
@@ -0,0 +1,9 @@
1
+ class PoliciesResource:
2
+ def __init__(self, client):
3
+ self._c = client
4
+
5
+ def get(self):
6
+ return self._c._http.request("GET", f"/workspaces/{self._c.workspace_id}/policies")
7
+
8
+ def update(self, **fields):
9
+ return self._c._http.request("PATCH", f"/workspaces/{self._c.workspace_id}/policies", json=fields)
@@ -0,0 +1,125 @@
1
+ import itertools
2
+
3
+ from .._sse import iter_sse_events
4
+
5
+ # Safety cap: if a stream never carries an agent_run_id (a malformed or
6
+ # very early error case), stop eagerly buffering after this many events
7
+ # rather than draining a whole broken stream into memory.
8
+ _MAX_BUFFER_WHILE_RESOLVING_RUN_ID = 5
9
+
10
+
11
+ class Run:
12
+ """
13
+ Wraps one agent execution's SSE stream. For a brand-new conversation,
14
+ CustomAgentChatView emits `conversation.assigned` first (conversation_id,
15
+ no agent_run_id yet), then the runtime's own first event carries
16
+ agent_run_id — so resolving the run's id may take up to two events. For
17
+ an existing conversation, the first event already carries agent_run_id.
18
+ Buffered events are re-yielded, in order, the first time stream() runs.
19
+ """
20
+
21
+ def __init__(self, client, id, session=None):
22
+ self._c = client
23
+ self.id = id
24
+ self.session = session
25
+ self._buffered_events = []
26
+ self._events_iter = None
27
+
28
+ @classmethod
29
+ def _from_stream(cls, client, response, session=None):
30
+ events_iter = iter_sse_events(response)
31
+ buffered = []
32
+ run_id = None
33
+ conversation_id = None
34
+
35
+ for event in events_iter:
36
+ buffered.append(event)
37
+ data = event.get("data") or {}
38
+ run_id = data.get("agent_run_id") or run_id
39
+ conversation_id = data.get("conversation_id") or conversation_id
40
+ if run_id is not None or len(buffered) >= _MAX_BUFFER_WHILE_RESOLVING_RUN_ID:
41
+ break
42
+
43
+ run = cls(client, id=run_id, session=session)
44
+ run._buffered_events = buffered
45
+ run._events_iter = events_iter
46
+
47
+ if session is not None and session.id is None and conversation_id is not None:
48
+ session.id = conversation_id # lazy Session resolves its id from the first stream
49
+
50
+ return run
51
+
52
+ def stream(self):
53
+ """
54
+ Yields events as they arrive. On `tool.execution_requested` (Phase
55
+ 6, docs/PLATFORM_PHASE_6_SPEC.md §3.3), transparently executes the
56
+ matching @z.tools.local(...) handler(s), submits the results, and
57
+ continues yielding from the continuation stream — the caller never
58
+ sees the pause. `approval.required` is NOT auto-handled; that still
59
+ needs a human decision via run.resume(...).
60
+
61
+ buffered and self._events_iter are chained into a SINGLE generator
62
+ before draining — a pause resolved entirely from buffered events
63
+ (the very first turn) must not leave a second, separate drain of
64
+ self._events_iter trying to read the now-exhausted original stream.
65
+ """
66
+ buffered, self._buffered_events = self._buffered_events, []
67
+ all_events = itertools.chain(buffered, self._events_iter if self._events_iter is not None else ())
68
+ yield from self._drain(all_events)
69
+
70
+ def _drain(self, events):
71
+ pending_batch = []
72
+ expected_batch_size = None
73
+ for event in events:
74
+ if event.get("event") == "tool.execution_requested":
75
+ pending_batch.append(event["data"])
76
+ expected_batch_size = event["data"].get("batch_size", 1)
77
+ if len(pending_batch) < expected_batch_size:
78
+ continue # wait for the rest of the batch before executing anything
79
+ yield from self._execute_and_continue(pending_batch)
80
+ return # the continuation's own stream()/_drain() has taken over completely
81
+ else:
82
+ yield event
83
+
84
+ def _execute_and_continue(self, pending_batch):
85
+ results = []
86
+ for call in pending_batch:
87
+ try:
88
+ output = self._c.tools._execute_local(call["tool"], call["input"])
89
+ results.append({"tool_call_id": call["tool_call_id"], "output": output, "error": None})
90
+ except Exception as exc:
91
+ results.append({"tool_call_id": call["tool_call_id"], "output": None, "error": str(exc)})
92
+
93
+ resp = self._c._http.stream(
94
+ "POST", f"/workspaces/{self._c.workspace_id}/runs/{self.id}/submit-tool-results",
95
+ json={"results": results},
96
+ )
97
+ continuation = Run._from_stream(self._c, resp, session=self.session)
98
+ yield from continuation.stream()
99
+
100
+ def get(self):
101
+ return self._c._http.request("GET", f"/workspaces/{self._c.workspace_id}/runs/{self.id}")
102
+
103
+ def get_trace(self):
104
+ return self._c._http.request("GET", f"/workspaces/{self._c.workspace_id}/runs/{self.id}/trace")
105
+
106
+ def resume(self, decision: str, reason: str = ""):
107
+ resp = self._c._http.stream(
108
+ "POST", f"/workspaces/{self._c.workspace_id}/runs/{self.id}/resume",
109
+ json={"decision": decision, "reason": reason},
110
+ )
111
+ return Run._from_stream(self._c, resp, session=self.session)
112
+
113
+
114
+ class RunsResource:
115
+ def __init__(self, client):
116
+ self._c = client
117
+
118
+ def get(self, run_id):
119
+ return self._c._http.request("GET", f"/workspaces/{self._c.workspace_id}/runs/{run_id}")
120
+
121
+ def get_trace(self, run_id):
122
+ return self._c._http.request("GET", f"/workspaces/{self._c.workspace_id}/runs/{run_id}/trace")
123
+
124
+ def resume(self, run_id, decision: str, reason: str = ""):
125
+ return Run(self._c, id=run_id).resume(decision, reason)
@@ -0,0 +1,49 @@
1
+ from .runs import Run
2
+
3
+
4
+ class Session:
5
+ """A Session is a Conversation (docs/PLATFORM_PHASE_5_SPEC.md decision 3).
6
+ `id` is None for a lazily-created session until its first run() resolves
7
+ a server-assigned conversation id from the stream."""
8
+
9
+ def __init__(self, client, id, agent_id):
10
+ self._c = client
11
+ self.id = id
12
+ self.agent_id = agent_id
13
+
14
+ def run(self, prompt: str) -> Run:
15
+ payload = {"message": prompt}
16
+ if self.id is not None:
17
+ payload["conversation_id"] = self.id
18
+ # Phase 6 (docs/PLATFORM_PHASE_6_SPEC.md §3.2): every locally
19
+ # registered tool's schema rides along on this one request — never
20
+ # persisted server-side, only meaningful for this run.
21
+ local_specs = self._c.tools._local_specs()
22
+ if local_specs:
23
+ payload["client_tools"] = local_specs
24
+ resp = self._c._http.stream(
25
+ "POST", f"/workspaces/{self._c.workspace_id}/agents/{self.agent_id}/chat", json=payload,
26
+ )
27
+ return Run._from_stream(self._c, resp, session=self)
28
+
29
+
30
+ class SessionsResource:
31
+ def __init__(self, client):
32
+ self._c = client
33
+
34
+ def create(self, agent_id) -> Session:
35
+ """Explicit creation — POSTs to conversations immediately."""
36
+ data = self._c._http.request(
37
+ "POST", f"/workspaces/{self._c.workspace_id}/conversations", json={"agent_id": agent_id},
38
+ )
39
+ return Session(self._c, id=data["id"], agent_id=agent_id)
40
+
41
+ def new(self, agent_id) -> Session:
42
+ """Lazy creation — no HTTP call yet; the first run() call creates
43
+ the conversation server-side and this Session captures its id from
44
+ the stream's conversation.assigned event."""
45
+ return Session(self._c, id=None, agent_id=agent_id)
46
+
47
+ def get(self, session_id) -> Session:
48
+ data = self._c._http.request("GET", f"/workspaces/{self._c.workspace_id}/conversations/{session_id}")
49
+ return Session(self._c, id=data["id"], agent_id=data.get("agent_id"))
@@ -0,0 +1,63 @@
1
+ import json
2
+
3
+ from .._schema import build_schema_from_function
4
+ from ..exceptions import ZonradToolNotRegisteredError
5
+
6
+
7
+ class ToolsResource:
8
+ """Read-only listing (Phase 5) plus local tool registration (Phase 6,
9
+ docs/PLATFORM_PHASE_6_SPEC.md §3.1) — one z.tools namespace for
10
+ everything tool-related."""
11
+
12
+ def __init__(self, client):
13
+ self._c = client
14
+ self._local = {} # name -> {"spec": {...}, "fn": callable}
15
+
16
+ def list(self):
17
+ return self._c._http.request("GET", f"/workspaces/{self._c.workspace_id}/tools")
18
+
19
+ def local(self, name=None, description=None, parameters=None):
20
+ """Registers a plain Python function as a client-side tool. Its
21
+ schema travels with every session.run() call this process makes —
22
+ it is never sent, stored, or visible anywhere else."""
23
+ def decorator(fn):
24
+ resolved_name = name or fn.__name__
25
+ schema = parameters or build_schema_from_function(fn)
26
+ spec = {
27
+ "name": resolved_name,
28
+ "description": description or (fn.__doc__ or "").strip() or f"Local tool '{resolved_name}'.",
29
+ "parameters": schema,
30
+ }
31
+ self._local[resolved_name] = {"spec": spec, "fn": fn}
32
+ return fn
33
+ return decorator
34
+
35
+ def _local_specs(self):
36
+ return [entry["spec"] for entry in self._local.values()]
37
+
38
+ def _execute_local(self, name, arguments):
39
+ entry = self._local.get(name)
40
+ if not entry:
41
+ raise ZonradToolNotRegisteredError(name)
42
+ # Defensive: the wire contract expects `arguments` already
43
+ # JSON-decoded, but a proxy or future server change could hand this
44
+ # back as a raw JSON string — **arguments would then raise TypeError
45
+ # instead of a clear, catchable error.
46
+ if isinstance(arguments, str):
47
+ try:
48
+ arguments = json.loads(arguments)
49
+ except (ValueError, TypeError):
50
+ arguments = {}
51
+ elif not isinstance(arguments, dict):
52
+ arguments = {}
53
+ return entry["fn"](**arguments)
54
+
55
+
56
+ class ToolkitsResource:
57
+ """Grouped view of tools by toolkit — distinct shape from ToolsResource."""
58
+
59
+ def __init__(self, client):
60
+ self._c = client
61
+
62
+ def list(self):
63
+ return self._c._http.request("GET", f"/workspaces/{self._c.workspace_id}/toolkits")
@@ -0,0 +1,217 @@
1
+ Metadata-Version: 2.4
2
+ Name: zonrad-sdk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the Zonrad platform (/platform/v1/) — agents, sessions, runs, tools, and connections.
5
+ Author: Zonrad
6
+ License: Proprietary
7
+ Project-URL: Homepage, https://zonrad.ai
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: requests>=2.28
14
+
15
+ # Zonrad Python SDK — Quickstart
16
+
17
+ The Zonrad SDK talks to the `/platform/v1/` boundary of your Zonrad workspace:
18
+ create agents, run them, stream their output, and inspect what they did —
19
+ all from a Python script, with no Django code in sight.
20
+
21
+ This guide is copy-paste runnable end to end.
22
+
23
+ ## 1. Install
24
+
25
+ ```bash
26
+ pip install zonrad-sdk
27
+ ```
28
+
29
+ The package on PyPI is named `zonrad-sdk` (`zonrad` was already taken), but
30
+ the import is unaffected: `from zonrad import Zonrad`, exactly as below.
31
+
32
+ Working against an unreleased local checkout of this repo instead? Use
33
+ `pip install -e sdk/python` — same import, no PyPI round trip.
34
+
35
+ ## 2. Get an API key
36
+
37
+ Every request needs a `PlatformAPIKey`, scoped to exactly one workspace.
38
+ Mint one with an authenticated session (e.g. your zonrad-ui login) against
39
+ the key-management endpoint:
40
+
41
+ ```bash
42
+ curl -X POST https://<your-zonrad-host>/platform/v1/workspaces/<workspace_id>/api-keys \
43
+ -H "Authorization: Bearer <your JWT access token>" \
44
+ -H "Content-Type: application/json" \
45
+ -d '{"name": "my laptop"}'
46
+ ```
47
+
48
+ The response looks like:
49
+
50
+ ```json
51
+ {
52
+ "id": 1,
53
+ "name": "my laptop",
54
+ "prefix": "zk_live_7792c540",
55
+ "key": "zk_live_7792c540_9dQwhgufdWlqEkq5hZy-fD5IaZffHAHy",
56
+ "workspace_id": 38,
57
+ "last_used_at": null,
58
+ "revoked_at": null,
59
+ "created_at": "2026-09-18T11:29:08.140046Z"
60
+ }
61
+ ```
62
+
63
+ **Save the `key` and `workspace_id` now** — the raw key is shown exactly
64
+ once and can never be retrieved again (only its prefix, for identifying it
65
+ later). If you lose it, mint a new one and revoke the old.
66
+
67
+ ## 3. Your first agent, session, and run
68
+
69
+ ```python
70
+ from zonrad import Zonrad
71
+
72
+ z = Zonrad(api_key="zk_live_...", workspace_id=38)
73
+
74
+ agent = z.agents.create(
75
+ name="Release Notes Bot",
76
+ instructions="Summarize engineering updates concisely for a non-technical audience.",
77
+ )
78
+
79
+ session = z.sessions.new(agent_id=agent["id"]) # lazy — no request sent yet
80
+ run = session.run("Say hello in exactly three words.")
81
+
82
+ for event in run.stream():
83
+ if event["event"] == "response.chunk":
84
+ print(event["data"]["content"], end="", flush=True)
85
+ elif event["event"] == "response.completed":
86
+ print() # final newline
87
+
88
+ print("session id (assigned by the server):", session.id)
89
+ print("run id:", run.id)
90
+ ```
91
+
92
+ `workspace_id` is set once, at client construction — every resource call
93
+ uses it automatically, since your API key is already scoped to that one
94
+ workspace.
95
+
96
+ `z.sessions.new()` is lazy: no HTTP request happens until the first
97
+ `.run()`. Use `z.sessions.create(agent_id=...)` instead if you want the
98
+ conversation to exist before you send the first message (e.g. to save its
99
+ `id` before running anything).
100
+
101
+ ## 4. Inspecting a trace
102
+
103
+ Every run is recorded. If something looks wrong, pull the full trace —
104
+ every model call and tool call, in order, with token counts:
105
+
106
+ ```python
107
+ trace = z.runs.get_trace(run.id)
108
+ for span in trace["spans"]:
109
+ print(span["type"], span["name"], span.get("total_tokens"))
110
+ ```
111
+
112
+ ## 5. Running your own Python function as a tool
113
+
114
+ `@z.tools.local(...)` registers a plain Python function that runs **on
115
+ this machine** — the agent calls it mid-run, the SDK executes it locally,
116
+ and the conversation continues automatically. No backend deploy, no new
117
+ tool registration anywhere except this script:
118
+
119
+ ```python
120
+ from zonrad import Zonrad
121
+
122
+ z = Zonrad(api_key="zk_live_...", workspace_id=38)
123
+
124
+ @z.tools.local()
125
+ def get_local_file(path: str) -> dict:
126
+ """Reads a file from this machine's filesystem."""
127
+ with open(path) as f:
128
+ return {"content": f.read()}
129
+
130
+ agent = z.agents.create(
131
+ name="File Assistant",
132
+ instructions="Use get_local_file to answer questions about files on the user's machine.",
133
+ )
134
+ session = z.sessions.new(agent_id=agent["id"])
135
+ run = session.run("What's in ~/notes.txt?")
136
+
137
+ for event in run.stream():
138
+ if event["event"] == "response.chunk":
139
+ print(event["data"]["content"], end="", flush=True)
140
+ ```
141
+
142
+ That's the whole thing — `run.stream()` sees the model call
143
+ `get_local_file`, runs your function, sends the result back, and keeps
144
+ streaming. You never handle the pause yourself.
145
+
146
+ The function's parameter types (`str`, `int`, `float`, `bool`, `list`,
147
+ `dict`, `Optional[...]`) are turned into the schema the model sees — same
148
+ rule as the backend's own `register_tool()` below, so a function looks the
149
+ same either way. Every parameter needs a type hint. Give the tool an
150
+ explicit name/description if the function's own name/docstring aren't
151
+ what you want the model to see: `@z.tools.local(name="read_file",
152
+ description="...")`.
153
+
154
+ **What this can't do:** a name that collides with an existing server-side
155
+ tool, or one your workspace has denied via policy, is rejected with a
156
+ clear error the moment you call `.run()` — not silently ignored. And a
157
+ local tool only exists for runs started by *this* process; it's not
158
+ visible to other users or other scripts, and `z.tools.list()` still only
159
+ shows the server-side registry (see below).
160
+
161
+ ## 6. Adding a tool for everyone (backend registration)
162
+
163
+ If a tool should be available to any agent in the workspace, not just from
164
+ your own script, register it in the backend repo instead:
165
+
166
+ ```python
167
+ # backend_django/apps/custom_agents/tools/my_tool.py
168
+ from apps.custom_agents.tool_registry import register_tool
169
+
170
+ @register_tool(
171
+ id="fetch_release_notes",
172
+ name="Fetch Release Notes",
173
+ description="Fetches the latest release notes for a given repo.",
174
+ risk_level="low",
175
+ )
176
+ def fetch_release_notes(repo: str) -> dict:
177
+ ...
178
+ return {"notes": "..."}
179
+ ```
180
+
181
+ This requires a backend deploy and then shows up in `z.tools.list()` for
182
+ everyone — unlike `@z.tools.local(...)`, which is private to the process
183
+ that registered it and needs no deploy at all.
184
+
185
+ ## What's not supported yet
186
+
187
+ - **Non-Python SDKs.** Python only, for now.
188
+ - **Streaming partial output *from* a local tool.** Your function's return
189
+ value is submitted as one complete result — there's no equivalent of
190
+ `response.chunk` for a tool's own execution.
191
+
192
+ ## Reference
193
+
194
+ ```python
195
+ z.agents.list() / .create(**fields) / .get(id) / .update(id, **fields) / .delete(id)
196
+ z.sessions.create(agent_id) / .new(agent_id) / .get(session_id)
197
+ session.run(prompt) -> Run
198
+ run.stream() # generator of {"event": ..., "data": {...}} dicts — auto-handles local tool calls
199
+ run.get() / .get_trace() / .resume(decision, reason="")
200
+ z.runs.get(run_id) / .get_trace(run_id) / .resume(run_id, decision, reason="")
201
+ z.tools.list() / z.toolkits.list() # server-side registry (read-only)
202
+ z.tools.local(name=None, description=None, parameters=None) # decorator — registers a local function
203
+ z.connections.list()
204
+ z.models.list()
205
+ z.policies.get() / .update(**fields)
206
+ z.api_keys.list() / .create(name="") / .revoke(key_id)
207
+ ```
208
+
209
+ A `tool.execution_requested` event your own code never has to handle
210
+ raises `zonrad.ZonradToolNotRegisteredError` internally if no matching
211
+ `@z.tools.local(...)` handler exists — the SDK catches this itself and
212
+ submits it as that call's error, so the run continues rather than your
213
+ script crashing.
214
+
215
+ Errors raise `zonrad.ZonradAPIError` with `.status_code`, `.error_code`
216
+ (when the server sent one, e.g. `"insufficient_credits"`), and `.body`
217
+ (the full parsed response).
@@ -0,0 +1,19 @@
1
+ zonrad/__init__.py,sha256=6cOzJaRedb855ouQiYKt8R8gTpwUrWSUkMxJYYUJb1o,196
2
+ zonrad/_http.py,sha256=SnhKQumO7A9Av5s0rHQzuJ87MzxHFVjzJptnnUo3s38,887
3
+ zonrad/_schema.py,sha256=aOfjbNxLPgCBCfHMaABVIpOzvQV2v3C-rB--egprMB4,2515
4
+ zonrad/_sse.py,sha256=8aS6Ft2kXbd1pfvrjUe_S_R6lD_hb97FCXUZ0aIY05U,692
5
+ zonrad/client.py,sha256=QWRgHULlR_DprnJY6cnftYVf_QFIT5Z_OIc7O0cXscQ,1407
6
+ zonrad/exceptions.py,sha256=BFMPOR-U8IYQYaR7kExCMSrwhorhizsoGVt7bL81vz4,1228
7
+ zonrad/resources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ zonrad/resources/agents.py,sha256=iKMKa8pN2gLQ70UsnqEeQGADxMiqx7lhMuV8kCfyXvg,844
9
+ zonrad/resources/api_keys.py,sha256=VpgQYxgiTF6niaysgO70KKrcDmR2y7euFibvKchNMik,622
10
+ zonrad/resources/connections.py,sha256=gh_rJJRBiqS6-okdOhK59HvWS02gYWQnV1cMhN-e-Fw,215
11
+ zonrad/resources/models.py,sha256=LWa15gQjgpAbzzuXpZDznY7uVqDNQ0jSia4Ds2tHlbQ,190
12
+ zonrad/resources/policies.py,sha256=dvkfZ1KS-rLii91fdc-qJUnBtcVv6iVynk2TnYD1kSk,333
13
+ zonrad/resources/runs.py,sha256=3kirVDDdnArkrK2k_BbVCFd_1URrfHy0fk19oHgwyn4,5315
14
+ zonrad/resources/sessions.py,sha256=LgfGDNH43U6r_l3dDB9zbfKS_UtoSqZba6iXOP2A-z4,2034
15
+ zonrad/resources/tools.py,sha256=B5gyiPQI68zRrPzyZKDBoyI2oIgNwvsRbDeAV1ql6r0,2404
16
+ zonrad_sdk-0.1.0.dist-info/METADATA,sha256=0zIMqaWxkb21M9FZcegSU7nLr36kJkrzVPFnk2RmYAI,7730
17
+ zonrad_sdk-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
18
+ zonrad_sdk-0.1.0.dist-info/top_level.txt,sha256=MBtWQ16cbC8MZtWc1Xm2MI33qkJ84yWEysEk3byC898,7
19
+ zonrad_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ zonrad