Metadata-Version: 2.4
Name: jan-adk
Version: 0.1.0.dev36848719683+ge0c62f863a1b.1
Summary: Python client for the Jan agent runtime: sessions, streamed turns, and host tools over `jan cli agent rpc`
Author-email: Jan <service@jan.ai>
License: AGPL-3.0
Project-URL: Homepage, https://jan.ai
Project-URL: Repository, https://github.com/janhq/jan
Keywords: jan,agent,adk,json-rpc
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: License :: OSI Approved :: GNU Affero General Public License v3
Requires-Python: >=3.11
Description-Content-Type: text/markdown

# jan-adk

Python client for the Jan agent runtime: sessions, streamed turns, and the host
tools your own process runs. It speaks the JSON-RPC channel of
`jan cli agent rpc`, so an embedding application gets one long-lived runtime
process serving many addressable sessions.

- Python 3.11+, standard library only - no Node, no third-party packages.
- The runtime is spawned and owned: a crash, an EOF or `close()` settles every
  parked turn and tool call, and the process is reaped.
- Types are generated from `protocol/rpc-schema.json`, the same document the
  Rust dispatcher publishes.

## Install

It is a preview and not on PyPI yet. Install the latest nightly wheel; no
repository clone or Node is required:

```bash
python -m pip install "$(python -c "import json, urllib.request as r; p = json.load(r.urlopen('https://delta.jan.ai/adk-nightly/manifest.json'))['packages']['python']; print(p['url'] + '#sha256=' + p['sha256'])")"
```

The URL is versioned and digest-pinned. That build's manifest also pins its
matching runtime:
`install_runtime(manifest_url=manifest["runtime"]["manifestUrl"], version=manifest["runtime"]["version"])`.
See [nightly installation](https://jan.ai/docs/agent/adk-nightly) for PowerShell
and the source-install fallback.

The runtime binary is separate, and there are three ways to have one: on `PATH`,
named by `$JAN_BIN` (the `bin` argument overrides both), or installed by this
package from the channel Jan publishes. That channel is a manifest naming, per
platform, the artifact and its SHA-256, so an install is reproducible rather
than whatever the URL serves today:

```python
from jan_adk import JanRuntime, install_runtime

runtime_bin = install_runtime()            # downloaded once, then cached
with JanRuntime.start(bin=runtime_bin.bin) as runtime:
    ...
```

`install_runtime()` maps this platform to its artifact (`darwin-universal`,
`linux-x86_64`, `linux-aarch64`, `windows-x86_64`, `windows-aarch64`), verifies
the published digest before extracting anything, and only then renames the
install into `JAN_AGENT_HOME` or the per-user cache directory. An install that
failed its digest, or that was interrupted mid-extract, is never visible as one:
`find_runtime()` answers `None` for it. No Node and no third-party package are
involved.

Each successful download gets an immutable generation directory. Only the small
lookup marker is atomically replaced, so concurrent installs and republished
versions cannot delete or change a binary path already returned to a caller.
Old generations remain until the cache is manually removed while no runtimes
are using it. Relative cache roots are resolved to absolute paths.
Tar extraction requires Python's safe data filter (Python 3.11.4+); an older
interpreter is refused rather than falling back to unsafe extraction.

`version` and `sha256` pin. A matching cached install is returned without network
access; otherwise both are checked against the manifest. A mismatch is an error
rather than a substitution. `manifest_url` names another channel;
the default is the nightly one. The macOS artifact is notarized, and getting a
runtime this way needs no Rust toolchain and no Jan Desktop.

```python
pinned = install_runtime(version="0.8.4-50")   # must be what the manifest publishes
found = find_runtime("0.8.4-50")               # reads the cache, no network
```

The nightly channel also publishes a shell installer, which is what the
[jan.ai ADK pages](https://jan.ai/docs/agent/adk) use:

```bash
curl -fsSL https://delta.jan.ai/jan-cli/install-jan-agent.sh | bash
```

To build your own instead, `make agent` in the Jan repository.

## Quickstart

```python
from jan_adk import JanRuntime

with JanRuntime.start() as runtime:
    session = runtime.create_session(model="gpt-4o-mini", ephemeral=True)
    turn = session.prompt("Summarise this table in one line.")

    for event in turn:                      # blocks until the turn ends
        if event["type"] == "token":
            print(event["text"], end="", flush=True)

    print(turn.result().stop_reason)
```

`prompt()` returns as soon as the turn starts; iterating the turn yields the
events while it runs, and `result()` blocks for the terminal record. Iterating
is optional - a host that only calls `result()` still gets it - but an undrained
turn keeps at most `max_buffered_events` (default 100000) *unread* events and
then drops its oldest, counting them on `turn.dropped`. The cap is the buffer,
not the turn: a reader that keeps up with a turn of any length loses nothing.

## Host tools

A host tool is declared with the schema the model sees, what it does, and the
handler that runs it here:

```python
from jan_adk import HostTool, JanRuntime

def observe(args, call):
    return {"text": "the bench holds a red bin", "images": [frame_path]}

def move(args, call):
    arm.move(args["position"], abort=call.aborted)   # a threading.Event
    return {"text": f"moved to {args['position']}", "details": {"joints": 3}}

with JanRuntime.start() as runtime:
    session = runtime.create_session(
        model="gpt-4o-mini",
        builtins=False,                               # only the tools below
        permissions="host",                           # this process gates them itself
        tools=[
            HostTool(
                name="camera_observe",
                description="Look at the robot bench and describe what is there.",
                parameters={"type": "object", "properties": {}, "additionalProperties": False},
                capability="read",                    # never prompted, concurrent
                handler=observe,
            ),
            HostTool(
                name="robot_arm_move",
                description="Move the arm to a named position.",
                parameters={
                    "type": "object",
                    "properties": {"position": {"type": "string"}},
                    "required": ["position"],
                },
                capability="actuator",                 # prompted, sequential
                handler=move,
            ),
        ],
    )
```

What a handler answers with:

| Field | Meaning |
| --- | --- |
| `text` | Tool text, sent to the model. |
| `images` | A path, a data URL, `{data, mimeType}`, or `{url}`. A tool message cannot carry an image, so `text` stays on the tool message and the runtime leads the next user turn with the image parts. |
| `details` | Host-only data, echoed as `item/tool_details` and never sent to the model. |
| `error` / `isError` | The call failed; the message is the tool result the model sees. |

A raised exception is answered the same way, so a failed call never leaves the
turn parked. Each handler runs on its own thread, so the client's reader is
never held and two subagents may have calls in flight at once: serializing them
- one native call at a time, on a shared robot - is the host's decision through
its own queue. `call.aborted` is set when the runtime withdraws the request (an
interrupt, or the client going away), and a late answer is not written.

Requests from a delegated child arrive on the same session carrying `run_id`;
the answer is by `request_id` alone, so one path serves both.

`session.tools` and `session.tool_specs` are what the runtime advertises, read
back rather than assumed: the names there are the declared ones with their
`host__` prefix, because that is what the model calls. The `tool_request` a
handler runs for carries the bare name the host declared.

### Permissions

An `actuator` is gated: the runtime asks before the call reaches the handler,
unless the session declares that the gate is the host's own.

- `permissions="host"` - for a session whose host tools this process runs and
  gates itself, which is the usual case for an embedding host. No prompt is
  raised for those tools, so a turn never waits on an answer nobody will give.
- `permissions="jan"` (the default) - the runtime owns the gate. A call to an
  `actuator` raises `permission_request` on the turn's stream, naming the
  advertised tool (`host__robot_arm_move`) and its `capability`. Answer it with
  `session.respond_permission(request_id, "allow_once" | "allow_always" | "deny")`,
  from the code draining the turn:

```python
for event in turn:
    if event["type"] == "permission_request":
        session.respond_permission(event["request_id"], "allow_once")
```

An unanswered request parks the turn: nothing moves until it is answered, the
turn is interrupted, or the runtime is closed. A `read` tool is never prompted
for, in either mode.

With `builtins=False`, `subagents=True` still lets the model delegate: each
subagent is held to these host tools, and its calls arrive here with its
`run_id`. `system_prompt=` replaces Jan's whole system prompt with yours.

## Process ownership

```python
session.interrupt()               # the turn settles with stop_reason "interrupted"
session.set_model("...")          # between turns; refused mid-turn with -32001
session.fork()                    # a new session with this one's history and model
session.archive()                 # drop it from the runtime
runtime.close()                   # close stdin, then the process
runtime.close(force=True)         # skip the grace period and kill
```

`runtime.limits` is what the handshake advertised - `max_images`,
`max_image_bytes`, `max_message_image_bytes` and the accepted `mime_types` - so
a client that sends an image can check it before sending rather than discover
the cap by being rejected. `JanRpcError` is an answer (`code`, `retryable`), and
`JanRuntimeError` is the channel (spawn failure, protocol mismatch, exit), with
the runtime's own stderr attached.

Listeners registered with `session.on(...)` run on the ADK's own dispatch
thread, in the order the runtime emitted the events, so a listener may call back
into the runtime - answering a permission request from one is the case that
matters - but it must not block for long: the dispatch thread is shared.

## Types

`jan_adk/_generated.py` is generated from `protocol/rpc-schema.json` -
request params, the event union, and the method and event-tag literals - by the
same generator the JavaScript ADK uses:

```bash
node packages/adk/scripts/generate.mjs            # rewrite both ADKs' types
node packages/adk/scripts/generate.mjs --check    # fail on drift
```

The artifact covers request params and events, not responses; the suite pins the
responses it uses against a real runtime.

## Tests

The suite drives a real runtime against a stub provider on loopback - no
credentials, no network, no Node:

```bash
JAN_BIN=/path/to/jan python3 -m unittest discover -s adk/python/tests -t adk/python
```

It is skipped, not failed, when `JAN_BIN` is unset.
