hanzoai: Dart client for the Hanzo Cloud /v1/ platform API

First-class services for agents, agent sessions, and machines with Hanzo
IAM-native Bearer auth; raw send() escape hatch for the full /v1/cloud/*
surface. Mirrors the @hanzo/cloud JavaScript client.

dart format + analyze --fatal-infos clean, 22 tests green, pub publish
dry-run: 0 warnings.
This commit is contained in:
2026-07-04 01:10:53 -07:00
commit fe31141cfd
19 changed files with 1758 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
name: ci
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dart-lang/setup-dart@v1
with:
sdk: stable
- run: dart pub get
- run: dart format --output=none --set-exit-if-changed .
- run: dart analyze --fatal-infos
- run: dart test
+19
View File
@@ -0,0 +1,19 @@
name: publish
# Publishes to pub.dev via OIDC when a version tag is pushed.
# Requires the package to have this repo configured as an automated
# publisher on pub.dev. Dormant until the first tag.
on:
push:
tags: ['v[0-9]+.[0-9]+.[0-9]+']
jobs:
publish:
permissions:
id-token: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dart-lang/setup-dart@v1
- run: dart pub get
- run: dart pub publish --force
+6
View File
@@ -0,0 +1,6 @@
# Dart / pub
.dart_tool/
.packages
build/
doc/api/
pubspec.lock
+8
View File
@@ -0,0 +1,8 @@
# Changelog
## 0.1.0
- Initial release. Typed Dart client for the Hanzo Cloud `/v1/` platform API.
- First-class services: `agents`, `sessions`, `machines`.
- Hanzo IAM-native Bearer auth (`MemoryAuthStore` / `AsyncAuthStore`).
- Raw `HanzoCloud.send()` escape hatch for the full `/v1/cloud/*` surface.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Hanzo AI, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+110
View File
@@ -0,0 +1,110 @@
# hanzoai
[![pub](https://img.shields.io/pub/v/hanzoai.svg)](https://pub.dev/packages/hanzoai)
The Dart client for the [Hanzo Cloud](https://hanzo.ai) `/v1/` platform API at
`api.hanzo.ai`. Auth is [Hanzo IAM](https://hanzo.id)-native: the client holds
the IAM-issued Bearer JWT (or an API key) and sends it on every request.
First-class services for **agents**, **agent sessions**, and **machines**, plus
a raw `send()` escape hatch that reaches the full `/v1/cloud/*` surface. Mirrors
the [`@hanzo/cloud`](https://github.com/hanzo-js/cloud) JavaScript client.
## Where it sits
Orthogonal, IAM-native clients — `hanzoai` is the platform control plane (create
an agent, run it, watch its sessions, provision its machine). It does not do
inference itself; pair it with the Hanzo AI inference surface for completions.
## Install
```yaml
dependencies:
hanzoai: ^0.1.0
```
```dart
import 'package:hanzoai/hanzoai.dart';
final cloud = HanzoCloud(token: 'IAM_JWT'); // origin defaults to api.hanzo.ai
```
## Agents
```dart
final agent = await cloud.agents.create(
name: 'researcher',
model: 'zen-coder',
instructions: 'You research and summarize.',
tools: ['search', 'fetch'],
);
final run = await cloud.agents.run('researcher', input: 'Summarize RFC 8628.');
print('${run.status}: ${run.output}');
final agents = await cloud.agents.list();
final detail = await cloud.agents.get('researcher');
final history = await cloud.agents.runs('researcher', limit: 20);
```
A long-running agent carries a 5-field cron and is invoked by the scheduler:
```dart
await cloud.agents.create(
name: 'nightly-digest',
model: 'zen-coder',
executionMode: 'long-running',
schedule: '0 9 * * *',
);
```
## Agent sessions
```dart
final session = await cloud.sessions.register(agent: 'researcher');
await cloud.sessions.appendEvent(session.id, kind: 'log', payload: {'msg': 'started'});
await cloud.sessions.pause(session.id);
await cloud.sessions.resume(session.id);
await cloud.sessions.message(session.id, message: 'focus on section 3');
final tree = await cloud.sessions.tree(session.id);
```
## Machines
```dart
final machines = await cloud.machines.list(pageSize: 50);
final one = await cloud.machines.get('org/name');
await cloud.machines.add(Machine(name: 'gpu-1', region: 'sfo3'));
```
## The full surface
Every other `/v1/cloud/*` resource (nodes, pods, containers, providers,
connections, stores, vectors, workflows, …) is reachable through `send()`:
```dart
final providers = await cloud.send('/v1/cloud/get-providers');
```
## Auth
```dart
cloud.setToken(jwt); // seed or replace the Bearer token
cloud.authStore.isValid; // token present and, if a JWT, unexpired
cloud.signOut(); // clear it
```
Errors are thrown as `HanzoException` with `statusCode` and `message`.
## Develop
```sh
dart pub get
dart analyze --fatal-infos
dart test
```
## License
MIT © Hanzo AI, Inc. See [LICENSE](LICENSE).
+7
View File
@@ -0,0 +1,7 @@
include: package:lints/recommended.yaml
analyzer:
language:
strict-casts: true
strict-raw-types: true
strict-inference: true
+25
View File
@@ -0,0 +1,25 @@
import 'package:hanzoai/hanzoai.dart';
/// Minimal end-to-end shape: create an agent, run it, watch its sessions.
///
/// Run against a real deployment with a Hanzo IAM token:
/// HANZO_TOKEN=... dart run example/hanzoai_example.dart
Future<void> main() async {
final cloud = HanzoCloud(token: const String.fromEnvironment('HANZO_TOKEN'));
final agent = await cloud.agents.create(
name: 'researcher',
model: 'zen-coder',
instructions: 'You research and summarize.',
tools: ['search', 'fetch'],
);
print('created ${agent.name} (${agent.id})');
final run = await cloud.agents.run(agent.name, input: 'Summarize RFC 8628.');
print('run ${run.status}: ${run.output}');
final sessions = await cloud.sessions.list(status: 'running');
print('${sessions.length} running session(s)');
cloud.close();
}
+28
View File
@@ -0,0 +1,28 @@
/// Dart client for the [Hanzo Cloud](https://hanzo.ai) `/v1/` platform API.
///
/// First-class services for agents, agent sessions, and machines, with
/// [Hanzo IAM](https://hanzo.id)-native Bearer auth. Mirrors the `@hanzo/cloud`
/// JavaScript client.
library;
export 'src/agents.dart' show AgentsService;
export 'src/auth_store.dart'
show AuthStore, MemoryAuthStore, AsyncAuthStore, AuthChangeCallback;
export 'src/client.dart' show HanzoCloud, defaultCloudUrl;
export 'src/exception.dart' show HanzoException;
export 'src/jwt.dart' show getTokenPayload, isTokenValid;
export 'src/machines.dart' show MachinesService;
export 'src/models.dart'
show
Agent,
AgentDetail,
Run,
Activity,
Session,
SessionDetail,
SessionEvent,
TreeNode,
ControlResult,
Machine,
CloudResponse;
export 'src/sessions.dart' show SessionsService;
+136
View File
@@ -0,0 +1,136 @@
import 'client.dart';
import 'models.dart';
/// The `/v1/agents` surface.
///
/// Agents are per-org autonomous units (a model + instructions + tool names).
/// A one-shot agent runs when passed to [run]; a long-running agent additionally
/// carries a 5-field cron `schedule` and is invoked by the scheduler.
class AgentsService {
AgentsService(this._client);
final HanzoCloud _client;
/// List the org's agents, most-recently-updated first.
Future<List<Agent>> list() async {
final res = await _client.send('/v1/agents');
final agents = (res is Map ? res['agents'] : null) as List? ?? const [];
return agents
.map((e) => Agent.fromJson((e as Map).cast<String, dynamic>()))
.toList();
}
/// Create an agent.
Future<Agent> create({
required String name,
required String model,
String? instructions,
String? description,
List<String>? tools,
String? executionMode,
String? schedule,
String? computeRef,
String? serviceAccountId,
}) async {
final res = await _client.send('/v1/agents',
method: 'POST',
body: _clean({
'name': name,
'model': model,
'instructions': instructions,
'description': description,
'tools': tools,
'executionMode': executionMode,
'schedule': schedule,
'computeRef': computeRef,
'serviceAccountId': serviceAccountId,
}));
return Agent.fromJson((res as Map).cast<String, dynamic>());
}
/// Get an agent by its public id (`agent_...`) or org-unique name.
Future<AgentDetail> get(String ref) async {
final res = await _client.send('/v1/agents/${Uri.encodeComponent(ref)}');
return AgentDetail.fromJson((res as Map).cast<String, dynamic>());
}
/// Update an agent's mutable fields (partial).
Future<Agent> update(
String ref, {
String? model,
String? instructions,
String? description,
List<String>? tools,
String? executionMode,
String? schedule,
String? computeRef,
String? serviceAccountId,
}) async {
final res = await _client.send(
'/v1/agents/${Uri.encodeComponent(ref)}',
method: 'PATCH',
body: _clean({
'model': model,
'instructions': instructions,
'description': description,
'tools': tools,
'executionMode': executionMode,
'schedule': schedule,
'computeRef': computeRef,
'serviceAccountId': serviceAccountId,
}),
);
return Agent.fromJson((res as Map).cast<String, dynamic>());
}
/// Delete an agent and its run history.
Future<void> delete(String ref) =>
_client.send('/v1/agents/${Uri.encodeComponent(ref)}', method: 'DELETE');
/// Run the agent once and return the completed run.
Future<Run> run(String ref, {String? input}) async {
final res = await _client.send(
'/v1/agents/${Uri.encodeComponent(ref)}/run',
method: 'POST',
body: input == null ? null : {'input': input},
);
return Run.fromJson((res as Map).cast<String, dynamic>());
}
/// The agent's run history, newest first.
Future<List<Run>> runs(String ref, {int? limit}) async {
final res = await _client.send(
'/v1/agents/${Uri.encodeComponent(ref)}/runs',
query: {if (limit != null) 'limit': limit},
);
final runs = (res is Map ? res['runs'] : null) as List? ?? const [];
return runs
.map((e) => Run.fromJson((e as Map).cast<String, dynamic>()))
.toList();
}
/// Invocations-over-time metrics for the org's Agents dashboard.
///
/// [range] is one of `24H`, `7D`, `30D`. Returns the raw metrics map.
Future<Map<String, dynamic>> metrics({String? range}) async {
final res = await _client.send(
'/v1/agents/metrics',
query: {if (range != null) 'range': range},
);
return res is Map ? res.cast<String, dynamic>() : <String, dynamic>{};
}
/// The org-wide recent-activity feed, newest first.
Future<List<Activity>> activity() async {
final res = await _client.send('/v1/agents/activity');
final activity = (res is Map ? res['activity'] : null) as List? ?? const [];
return activity
.map((e) => Activity.fromJson((e as Map).cast<String, dynamic>()))
.toList();
}
}
Map<String, dynamic> _clean(Map<String, dynamic> m) {
m.removeWhere((_, v) => v == null);
return m;
}
+142
View File
@@ -0,0 +1,142 @@
import 'dart:async';
import 'dart:convert';
import 'jwt.dart';
/// Callback invoked whenever the stored Bearer token changes.
typedef AuthChangeCallback = void Function(String token);
/// Holds the current Hanzo IAM-issued Bearer token (a JWT or an API key).
///
/// The default [MemoryAuthStore] keeps it in memory and validates a JWT's
/// `exp` claim locally. Provide an [AsyncAuthStore] to persist the token
/// (e.g. secure storage on mobile).
abstract class AuthStore {
String get token;
/// Whether a usable token is present (see [isTokenValid]).
bool get isValid => isTokenValid(token);
void save(String token);
void clear();
/// Register a change listener. Returns a function that removes it.
void Function() onChange(AuthChangeCallback callback,
{bool fireImmediately = false});
}
/// In-memory auth store. The default when none is supplied.
class MemoryAuthStore extends AuthStore {
String _token = '';
final _listeners = <AuthChangeCallback>{};
@override
String get token => _token;
@override
void save(String token) {
_token = token;
_notify();
}
@override
void clear() {
_token = '';
_notify();
}
@override
void Function() onChange(AuthChangeCallback callback,
{bool fireImmediately = false}) {
_listeners.add(callback);
if (fireImmediately) {
callback(_token);
}
return () => _listeners.remove(callback);
}
void _notify() {
for (final listener in _listeners.toList()) {
listener(_token);
}
}
}
/// Auth store backed by an async persistence layer.
///
/// Supply [save] to persist the token and [initial] to seed the store from
/// previously-saved state. The persisted value is an opaque JSON string.
///
/// ```dart
/// AsyncAuthStore(
/// save: (data) => storage.write(key: 'hanzo_cloud', value: data),
/// initial: await storage.read(key: 'hanzo_cloud'),
/// );
/// ```
class AsyncAuthStore extends AuthStore {
AsyncAuthStore({
required Future<void> Function(String data) save,
String? initial,
Future<void> Function()? clear,
}) : _save = save,
_clear = clear {
if (initial != null && initial.isNotEmpty) {
_load(initial);
}
}
final Future<void> Function(String data) _save;
final Future<void> Function()? _clear;
String _token = '';
final _listeners = <AuthChangeCallback>{};
@override
String get token => _token;
@override
void save(String token) {
_token = token;
_notify();
unawaited(_save(jsonEncode({'token': token})));
}
@override
void clear() {
_token = '';
_notify();
final clear = _clear;
if (clear != null) {
unawaited(clear());
} else {
unawaited(_save(''));
}
}
@override
void Function() onChange(AuthChangeCallback callback,
{bool fireImmediately = false}) {
_listeners.add(callback);
if (fireImmediately) {
callback(_token);
}
return () => _listeners.remove(callback);
}
void _load(String raw) {
try {
final data = jsonDecode(raw);
if (data is Map<String, dynamic>) {
_token = (data['token'] ?? '').toString();
}
} catch (_) {
// ignore corrupt persisted state
}
}
void _notify() {
for (final listener in _listeners.toList()) {
listener(_token);
}
}
}
+155
View File
@@ -0,0 +1,155 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'agents.dart';
import 'auth_store.dart';
import 'exception.dart';
import 'machines.dart';
import 'sessions.dart';
/// Default Hanzo Cloud API origin.
const String defaultCloudUrl = 'https://api.hanzo.ai';
/// The typed client for the Hanzo Cloud `/v1/` platform API.
///
/// ```dart
/// final cloud = HanzoCloud(token: 'IAM_JWT');
/// final agent = await cloud.agents.create(name: 'researcher', model: 'zen-coder');
/// final run = await cloud.agents.run('researcher', input: 'Summarize RFC 8628.');
/// ```
///
/// Auth is Hanzo IAM-native: [authStore] holds the IAM-issued Bearer JWT (or an
/// API key), sent as `Authorization: Bearer <token>` on every request.
class HanzoCloud {
HanzoCloud({
String baseUrl = defaultCloudUrl,
String? token,
AuthStore? authStore,
this.lang = 'en-US',
http.Client Function()? httpClientFactory,
}) : baseUrl = baseUrl.endsWith('/')
? baseUrl.substring(0, baseUrl.length - 1)
: baseUrl,
authStore = authStore ?? MemoryAuthStore() {
_httpClient = (httpClientFactory ?? http.Client.new)();
if (token != null && token.isNotEmpty) {
this.authStore.save(token);
}
agents = AgentsService(this);
sessions = SessionsService(this);
machines = MachinesService(this);
}
/// The Hanzo Cloud origin (e.g. `https://api.hanzo.ai`).
final String baseUrl;
/// Holds the current Hanzo IAM Bearer token.
final AuthStore authStore;
/// `Accept-Language` header sent with every request.
String lang;
/// The `/v1/agents` surface.
late final AgentsService agents;
/// The `/v1/agents/sessions` surface.
late final SessionsService sessions;
/// The `/v1/cloud/*-machine(s)` surface.
late final MachinesService machines;
late final http.Client _httpClient;
/// Seed or replace the Bearer token.
void setToken(String token) => authStore.save(token);
/// Clear the Bearer token.
void signOut() => authStore.clear();
/// Liveness probe (`GET /health`).
Future<Map<String, dynamic>> health() async {
final result = await send('/health');
return result is Map<String, dynamic> ? result : <String, dynamic>{};
}
/// Sends a request to the Hanzo Cloud API and returns the decoded JSON body.
///
/// Adds `Authorization: Bearer <token>` when a valid session exists. Throws
/// [HanzoException] on non-2xx responses.
Future<dynamic> send(
String path, {
String method = 'GET',
Map<String, String> headers = const {},
Map<String, dynamic> query = const {},
Object? body,
}) async {
final url = _buildUrl(path, query);
final request = http.Request(method, url);
if (body != null) {
request.body = jsonEncode(body);
request.headers['Content-Type'] = 'application/json';
}
request.headers.addAll(headers);
if (!request.headers.containsKey('Authorization') && authStore.isValid) {
request.headers['Authorization'] = 'Bearer ${authStore.token}';
}
request.headers.putIfAbsent('Accept-Language', () => lang);
http.StreamedResponse streamed;
try {
streamed = await _httpClient.send(request);
} on http.ClientException catch (e) {
throw HanzoException(url: url, originalError: e, isAbort: true);
} catch (e) {
throw HanzoException(url: url, originalError: e);
}
final responseBody = await streamed.stream.bytesToString();
dynamic data;
if (responseBody.isNotEmpty) {
try {
data = jsonDecode(responseBody);
} catch (_) {
data = responseBody;
}
}
if (streamed.statusCode >= 400) {
throw HanzoException(
url: url,
statusCode: streamed.statusCode,
response: data is Map<String, dynamic> ? data : const {},
);
}
return data;
}
/// Closes the underlying HTTP client.
void close() => _httpClient.close();
Uri _buildUrl(String path, Map<String, dynamic> query) {
final normalizedQuery = <String, dynamic>{};
query.forEach((key, value) {
if (value == null) {
return;
}
normalizedQuery[key] = value is Iterable
? value.map((v) => v.toString()).toList()
: value.toString();
});
final uri = Uri.parse('$baseUrl$path');
if (normalizedQuery.isEmpty) {
return uri;
}
return uri.replace(queryParameters: {
...uri.queryParametersAll,
...normalizedQuery,
});
}
}
+37
View File
@@ -0,0 +1,37 @@
/// Normalized error thrown by every Hanzo Cloud request.
class HanzoException implements Exception {
HanzoException({
this.url,
this.statusCode = 0,
this.response = const {},
this.originalError,
this.isAbort = false,
});
/// The request url that produced the error.
final Uri? url;
/// The HTTP status code, or `0` for transport/connection errors.
final int statusCode;
/// The parsed JSON error body, when present.
final Map<String, dynamic> response;
/// The underlying error for transport failures.
final Object? originalError;
/// Whether the request was aborted (connection dropped).
final bool isAbort;
/// Server-provided error message, if any.
String get message {
final msg = response['message'] ?? response['msg'];
if (msg is String && msg.isNotEmpty) {
return msg;
}
return originalError?.toString() ?? 'HanzoException $statusCode';
}
@override
String toString() => 'HanzoException($statusCode): $message';
}
+39
View File
@@ -0,0 +1,39 @@
import 'dart:convert';
/// Decode a JWT payload without verifying its signature.
///
/// Returns an empty map for tokens that are not three-part JWTs (e.g. opaque
/// API keys). Used to read the Hanzo IAM token's `exp` claim locally.
Map<String, dynamic> getTokenPayload(String token) {
final parts = token.split('.');
if (parts.length != 3) {
return const {};
}
try {
final decoded =
utf8.decode(base64Url.decode(base64Url.normalize(parts[1])));
final data = jsonDecode(decoded);
return data is Map<String, dynamic> ? data : const {};
} catch (_) {
return const {};
}
}
/// Whether a Bearer token is currently usable.
///
/// A three-part JWT is valid until its `exp` claim (minus
/// [expirationThreshold] seconds) passes. An opaque API key — anything that is
/// not a decodable JWT — is valid whenever it is non-empty, since it carries no
/// local expiry.
bool isTokenValid(String token, [int expirationThreshold = 0]) {
if (token.isEmpty) {
return false;
}
final payload = getTokenPayload(token);
final exp = payload['exp'];
if (exp is! num) {
return true; // opaque API key or JWT without exp
}
final nowSeconds = DateTime.now().millisecondsSinceEpoch / 1000;
return exp - expirationThreshold > nowSeconds;
}
+61
View File
@@ -0,0 +1,61 @@
import 'client.dart';
import 'models.dart';
/// The `/v1/cloud/*-machine(s)` surface — a worked example of the legacy
/// `/v1/cloud/*` platform CRUD.
///
/// Mutations return the [CloudResponse] envelope; reads return the object
/// directly. Every other `/v1/cloud/*` resource (nodes, pods, containers,
/// providers, connections, …) follows the same shape and is reachable via
/// [HanzoCloud.send].
class MachinesService {
MachinesService(this._client);
final HanzoCloud _client;
/// List machines (paginated).
Future<List<Machine>> list({int pageSize = 100, int page = 1}) async {
final res = await _client.send('/v1/cloud/get-machines',
query: {'pageSize': pageSize, 'p': page});
if (res is List) {
return res
.map((e) => Machine.fromJson((e as Map).cast<String, dynamic>()))
.toList();
}
// Casdoor-style envelope: the page rides in `data`.
final data = res is Map ? res['data'] : null;
if (data is List) {
return data
.map((e) => Machine.fromJson((e as Map).cast<String, dynamic>()))
.toList();
}
return const [];
}
/// Get one machine by its `owner/name` id.
Future<Machine> get(String id) async {
final res = await _client.send('/v1/cloud/get-machine', query: {'id': id});
return Machine.fromJson((res as Map).cast<String, dynamic>());
}
/// Create a machine.
Future<CloudResponse> add(Machine machine) async {
final res = await _client.send('/v1/cloud/add-machine',
method: 'POST', body: machine.toJson());
return CloudResponse.fromJson((res as Map).cast<String, dynamic>());
}
/// Update a machine by its `owner/name` id.
Future<CloudResponse> update(String id, Machine machine) async {
final res = await _client.send('/v1/cloud/update-machine',
method: 'POST', query: {'id': id}, body: machine.toJson());
return CloudResponse.fromJson((res as Map).cast<String, dynamic>());
}
/// Delete a machine.
Future<CloudResponse> delete(Machine machine) async {
final res = await _client.send('/v1/cloud/delete-machine',
method: 'POST', body: machine.toJson());
return CloudResponse.fromJson((res as Map).cast<String, dynamic>());
}
}
+508
View File
@@ -0,0 +1,508 @@
/// Typed domain models for the Hanzo Cloud API.
///
/// Each class mirrors a schema in the OpenAPI spec (`agents.*`,
/// `object.Machine`, `controllers.Response`). Fields absent from a response
/// take their zero value; `fromJson` never throws on a well-formed map.
library;
String _s(dynamic v) => v?.toString() ?? '';
int _i(dynamic v) => v is int
? v
: v is num
? v.toInt()
: v is String
? int.tryParse(v) ?? 0
: 0;
bool _b(dynamic v) => v == true;
List<String> _strList(dynamic v) =>
v is List ? v.map((e) => e.toString()).toList() : const [];
Map<String, dynamic> _map(dynamic v) =>
v is Map ? v.map((k, value) => MapEntry(k.toString(), value)) : const {};
/// A per-org autonomous agent — a model, instructions, and tool names.
class Agent {
Agent({
this.id = '',
this.name = '',
this.model = '',
this.description = '',
this.tools = const [],
this.status = '',
this.executionMode = '',
this.schedule = '',
this.computeRef = '',
this.serviceAccountId = '',
this.runs = 0,
this.createdAt = '',
this.updatedAt = '',
});
factory Agent.fromJson(Map<String, dynamic> json) => Agent(
id: _s(json['id']),
name: _s(json['name']),
model: _s(json['model']),
description: _s(json['description']),
tools: _strList(json['tools']),
status: _s(json['status']),
executionMode: _s(json['executionMode']),
schedule: _s(json['schedule']),
computeRef: _s(json['computeRef']),
serviceAccountId: _s(json['serviceAccountId']),
runs: _i(json['runs']),
createdAt: _s(json['createdAt']),
updatedAt: _s(json['updatedAt']),
);
final String id;
final String name;
final String model;
final String description;
final List<String> tools;
final String status;
/// `one-shot` or `long-running`.
final String executionMode;
/// 5-field cron; set only when [executionMode] is `long-running`.
final String schedule;
final String computeRef;
final String serviceAccountId;
final int runs;
final String createdAt;
final String updatedAt;
}
/// An [Agent] plus its instructions and recent runs.
class AgentDetail extends Agent {
AgentDetail({
super.id,
super.name,
super.model,
super.description,
super.tools,
super.status,
super.executionMode,
super.schedule,
super.computeRef,
super.serviceAccountId,
super.runs,
super.createdAt,
super.updatedAt,
this.instructions = '',
this.recentRuns = const [],
});
factory AgentDetail.fromJson(Map<String, dynamic> json) {
final base = Agent.fromJson(json);
return AgentDetail(
id: base.id,
name: base.name,
model: base.model,
description: base.description,
tools: base.tools,
status: base.status,
executionMode: base.executionMode,
schedule: base.schedule,
computeRef: base.computeRef,
serviceAccountId: base.serviceAccountId,
runs: base.runs,
createdAt: base.createdAt,
updatedAt: base.updatedAt,
instructions: _s(json['instructions']),
recentRuns: (json['recentRuns'] as List? ?? const [])
.map((e) => Run.fromJson(_map(e)))
.toList(),
);
}
final String instructions;
final List<Run> recentRuns;
}
/// One execution of an agent.
class Run {
Run({
this.id = '',
this.status = '',
this.model = '',
this.input = '',
this.output = '',
this.error = '',
this.durationMs = 0,
this.createdAt = '',
});
factory Run.fromJson(Map<String, dynamic> json) => Run(
id: _s(json['id']),
status: _s(json['status']),
model: _s(json['model']),
input: _s(json['input']),
output: _s(json['output']),
error: _s(json['error']),
durationMs: _i(json['durationMs']),
createdAt: _s(json['createdAt']),
);
final String id;
/// `ok` or `error`.
final String status;
final String model;
final String input;
final String output;
final String error;
final int durationMs;
final String createdAt;
}
/// One entry in the org-wide agent activity feed.
class Activity {
Activity({
this.id = '',
this.kind = '',
this.agent = '',
this.message = '',
this.at = '',
});
factory Activity.fromJson(Map<String, dynamic> json) => Activity(
id: _s(json['id']),
kind: _s(json['kind']),
agent: _s(json['agent']),
message: _s(json['message']),
at: _s(json['at']),
);
final String id;
/// `invoked`, `failed`, `created`, or `updated`.
final String kind;
final String agent;
final String message;
final String at;
}
/// A live agent-session — a node of the subagent tree.
class Session {
Session({
this.id = '',
this.agent = '',
this.actor = '',
this.status = '',
this.parentSessionId = '',
this.rootSessionId = '',
this.title = '',
this.taskWorkflowId = '',
this.taskRunId = '',
this.events = 0,
this.children = 0,
this.startedAt = '',
this.endedAt = '',
this.createdAt = '',
this.updatedAt = '',
});
factory Session.fromJson(Map<String, dynamic> json) => Session(
id: _s(json['id']),
agent: _s(json['agent']),
actor: _s(json['actor']),
status: _s(json['status']),
parentSessionId: _s(json['parentSessionId']),
rootSessionId: _s(json['rootSessionId']),
title: _s(json['title']),
taskWorkflowId: _s(json['taskWorkflowId']),
taskRunId: _s(json['taskRunId']),
events: _i(json['events']),
children: _i(json['children']),
startedAt: _s(json['startedAt']),
endedAt: _s(json['endedAt']),
createdAt: _s(json['createdAt']),
updatedAt: _s(json['updatedAt']),
);
final String id;
final String agent;
final String actor;
/// `running`, `paused`, `done`, or `error`.
final String status;
/// Empty for a root (the outer agent).
final String parentSessionId;
/// The tree key; equals [id] for a root.
final String rootSessionId;
final String title;
final String taskWorkflowId;
final String taskRunId;
final int events;
final int children;
final String startedAt;
final String endedAt;
final String createdAt;
final String updatedAt;
}
/// A [Session] plus its direct children and recent events.
class SessionDetail extends Session {
SessionDetail({
super.id,
super.agent,
super.actor,
super.status,
super.parentSessionId,
super.rootSessionId,
super.title,
super.taskWorkflowId,
super.taskRunId,
super.events,
super.children,
super.startedAt,
super.endedAt,
super.createdAt,
super.updatedAt,
this.childSessions = const [],
this.recentEvents = const [],
});
factory SessionDetail.fromJson(Map<String, dynamic> json) {
final base = Session.fromJson(json);
return SessionDetail(
id: base.id,
agent: base.agent,
actor: base.actor,
status: base.status,
parentSessionId: base.parentSessionId,
rootSessionId: base.rootSessionId,
title: base.title,
taskWorkflowId: base.taskWorkflowId,
taskRunId: base.taskRunId,
events: base.events,
children: base.children,
startedAt: base.startedAt,
endedAt: base.endedAt,
createdAt: base.createdAt,
updatedAt: base.updatedAt,
childSessions: (json['childSessions'] as List? ?? const [])
.map((e) => Session.fromJson(_map(e)))
.toList(),
recentEvents: (json['recentEvents'] as List? ?? const [])
.map((e) => SessionEvent.fromJson(_map(e)))
.toList(),
);
}
final List<Session> childSessions;
final List<SessionEvent> recentEvents;
}
/// One entry in a session's ordered event log.
class SessionEvent {
SessionEvent({
this.id = '',
this.sessionId = '',
this.seq = 0,
this.kind = '',
this.actor = '',
this.payload = const {},
this.createdAt = '',
});
factory SessionEvent.fromJson(Map<String, dynamic> json) => SessionEvent(
id: _s(json['id']),
sessionId: _s(json['sessionId']),
seq: _i(json['seq']),
kind: _s(json['kind']),
actor: _s(json['actor']),
payload: _map(json['payload']),
createdAt: _s(json['createdAt']),
);
final String id;
final String sessionId;
/// Monotonic per session.
final int seq;
/// `message`, `tool-call`, `spawn`, `log`, `status`, or `control`.
final String kind;
final String actor;
final Map<String, dynamic> payload;
final String createdAt;
}
/// One node of the recursive subagent-flow graph.
class TreeNode {
TreeNode({Session? session, this.children = const []})
: session = session ?? Session();
factory TreeNode.fromJson(Map<String, dynamic> json) => TreeNode(
session: Session.fromJson(_map(json['session'])),
children: (json['children'] as List? ?? const [])
.map((e) => TreeNode.fromJson(_map(e)))
.toList(),
);
final Session session;
final List<TreeNode> children;
}
/// The result of a session control command (pause/resume/stop/message).
class ControlResult {
ControlResult({this.command = '', this.event, this.forwarded = false});
factory ControlResult.fromJson(Map<String, dynamic> json) => ControlResult(
command: _s(json['command']),
event: json['event'] is Map
? SessionEvent.fromJson(_map(json['event']))
: null,
forwarded: _b(json['forwarded']),
);
/// `pause`, `resume`, `stop`, or `message`.
final String command;
final SessionEvent? event;
/// True when the command reached the hanzoai/tasks engine.
final bool forwarded;
}
/// A provisioned compute machine.
class Machine {
Machine({
this.id = '',
this.name = '',
this.owner = '',
this.displayName = '',
this.category = '',
this.type = '',
this.provider = '',
this.region = '',
this.zone = '',
this.image = '',
this.os = '',
this.size = '',
this.cpuSize = '',
this.memSize = '',
this.state = '',
this.tag = '',
this.publicIp = '',
this.privateIp = '',
this.remoteProtocol = '',
this.remotePort = 0,
this.remoteUsername = '',
this.createdTime = '',
this.updatedTime = '',
this.expireTime = '',
});
factory Machine.fromJson(Map<String, dynamic> json) => Machine(
id: _s(json['id']),
name: _s(json['name']),
owner: _s(json['owner']),
displayName: _s(json['displayName']),
category: _s(json['category']),
type: _s(json['type']),
provider: _s(json['provider']),
region: _s(json['region']),
zone: _s(json['zone']),
image: _s(json['image']),
os: _s(json['os']),
size: _s(json['size']),
cpuSize: _s(json['cpuSize']),
memSize: _s(json['memSize']),
state: _s(json['state']),
tag: _s(json['tag']),
publicIp: _s(json['publicIp']),
privateIp: _s(json['privateIp']),
remoteProtocol: _s(json['remoteProtocol']),
remotePort: _i(json['remotePort']),
remoteUsername: _s(json['remoteUsername']),
createdTime: _s(json['createdTime']),
updatedTime: _s(json['updatedTime']),
expireTime: _s(json['expireTime']),
);
/// Serialize back to the wire shape (non-empty fields only).
Map<String, dynamic> toJson() {
final m = <String, dynamic>{
'id': id,
'name': name,
'owner': owner,
'displayName': displayName,
'category': category,
'type': type,
'provider': provider,
'region': region,
'zone': zone,
'image': image,
'os': os,
'size': size,
'cpuSize': cpuSize,
'memSize': memSize,
'state': state,
'tag': tag,
'publicIp': publicIp,
'privateIp': privateIp,
'remoteProtocol': remoteProtocol,
'remotePort': remotePort,
'remoteUsername': remoteUsername,
'createdTime': createdTime,
'updatedTime': updatedTime,
'expireTime': expireTime,
};
m.removeWhere((k, v) => v == '' || v == 0);
return m;
}
final String id;
final String name;
final String owner;
final String displayName;
final String category;
final String type;
final String provider;
final String region;
final String zone;
final String image;
final String os;
final String size;
final String cpuSize;
final String memSize;
final String state;
final String tag;
final String publicIp;
final String privateIp;
final String remoteProtocol;
final int remotePort;
final String remoteUsername;
final String createdTime;
final String updatedTime;
final String expireTime;
}
/// The `{ status, msg, data, data2 }` envelope returned by the legacy
/// `/v1/cloud/*` (Casdoor-style) endpoints.
class CloudResponse {
CloudResponse({
this.status = '',
this.msg = '',
this.data,
this.data2,
});
factory CloudResponse.fromJson(Map<String, dynamic> json) => CloudResponse(
status: _s(json['status']),
msg: _s(json['msg']),
data: json['data'],
data2: json['data2'],
);
final String status;
final String msg;
final dynamic data;
final dynamic data2;
/// Whether the server reported success.
bool get ok => status == 'ok';
}
+139
View File
@@ -0,0 +1,139 @@
import 'client.dart';
import 'models.dart';
/// The `/v1/agents/sessions` surface.
///
/// A session is one live agent invocation. Sessions linked by `parentSessionId`
/// form the subagent tree; `rootSessionId` is the shared key.
class SessionsService {
SessionsService(this._client);
final HanzoCloud _client;
/// Register a live session. Omit [parentSessionId] for a root.
Future<Session> register({
required String agent,
String? actor,
String? title,
String? status,
String? parentSessionId,
String? taskWorkflowId,
String? taskRunId,
}) async {
final res =
await _client.send('/v1/agents/sessions', method: 'POST', body: {
'agent': agent,
if (actor != null) 'actor': actor,
if (title != null) 'title': title,
if (status != null) 'status': status,
if (parentSessionId != null) 'parentSessionId': parentSessionId,
if (taskWorkflowId != null) 'taskWorkflowId': taskWorkflowId,
if (taskRunId != null) 'taskRunId': taskRunId,
});
return Session.fromJson((res as Map).cast<String, dynamic>());
}
/// List the org's live sessions, newest first.
Future<List<Session>> list({
String? root,
String? parent,
String? status,
int? limit,
}) async {
final res = await _client.send('/v1/agents/sessions', query: {
if (root != null) 'root': root,
if (parent != null) 'parent': parent,
if (status != null) 'status': status,
if (limit != null) 'limit': limit,
});
final sessions = (res is Map ? res['sessions'] : null) as List? ?? const [];
return sessions
.map((e) => Session.fromJson((e as Map).cast<String, dynamic>()))
.toList();
}
/// Session detail — the session, its direct children, and recent events.
Future<SessionDetail> get(String id) async {
final res =
await _client.send('/v1/agents/sessions/${Uri.encodeComponent(id)}');
return SessionDetail.fromJson((res as Map).cast<String, dynamic>());
}
/// Update a session's status and/or title (terminal sessions are monotonic).
Future<Session> patch(String id, {String? status, String? title}) async {
final res = await _client.send(
'/v1/agents/sessions/${Uri.encodeComponent(id)}',
method: 'PATCH',
body: {
if (status != null) 'status': status,
if (title != null) 'title': title,
},
);
return Session.fromJson((res as Map).cast<String, dynamic>());
}
/// The full subagent-flow graph rooted at this session's tree.
Future<TreeNode> tree(String id) async {
final res = await _client
.send('/v1/agents/sessions/${Uri.encodeComponent(id)}/tree');
return TreeNode.fromJson((res as Map).cast<String, dynamic>());
}
/// Append one event to a session's ordered log.
Future<SessionEvent> appendEvent(
String id, {
required String kind,
String? actor,
Map<String, dynamic>? payload,
}) async {
final res = await _client.send(
'/v1/agents/sessions/${Uri.encodeComponent(id)}/events',
method: 'POST',
body: {
'kind': kind,
if (actor != null) 'actor': actor,
if (payload != null) 'payload': payload,
},
);
return SessionEvent.fromJson((res as Map).cast<String, dynamic>());
}
/// Pause a live session.
Future<ControlResult> pause(String id,
{String? message, Map<String, dynamic>? payload}) =>
_control(id, 'pause', message, payload);
/// Resume a paused session.
Future<ControlResult> resume(String id,
{String? message, Map<String, dynamic>? payload}) =>
_control(id, 'resume', message, payload);
/// Stop a session.
Future<ControlResult> stop(String id,
{String? message, Map<String, dynamic>? payload}) =>
_control(id, 'stop', message, payload);
/// Send a steering message to a live session (requires [message] or [payload]).
Future<ControlResult> message(String id,
{String? message, Map<String, dynamic>? payload}) =>
_control(id, 'message', message, payload);
Future<ControlResult> _control(
String id,
String command,
String? message,
Map<String, dynamic>? payload,
) async {
final res = await _client.send(
'/v1/agents/sessions/${Uri.encodeComponent(id)}/$command',
method: 'POST',
body: (message == null && payload == null)
? null
: {
if (message != null) 'message': message,
if (payload != null) 'payload': payload,
},
);
return ControlResult.fromJson((res as Map).cast<String, dynamic>());
}
}
+16
View File
@@ -0,0 +1,16 @@
name: hanzoai
description: Dart client for the Hanzo Cloud platform API — agents, agent sessions, and machines with Hanzo IAM-native auth.
version: 0.1.0
repository: https://github.com/hanzo-dart/hanzoai
homepage: https://hanzo.ai
issue_tracker: https://github.com/hanzo-dart/hanzoai/issues
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
http: ^1.3.0
dev_dependencies:
lints: ^5.0.0
test: ^1.24.0
+281
View File
@@ -0,0 +1,281 @@
import 'dart:convert';
import 'package:hanzoai/hanzoai.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:test/test.dart';
/// Builds an unsigned JWT with the given payload (for local exp checks).
String jwt(Map<String, dynamic> payload) {
String seg(Map<String, dynamic> m) =>
base64Url.encode(utf8.encode(jsonEncode(m))).replaceAll('=', '');
return '${seg({'alg': 'none'})}.${seg(payload)}.sig';
}
HanzoCloud clientWith(
Future<http.Response> Function(http.Request request) handler, {
String? token,
AuthStore? authStore,
}) {
return HanzoCloud(
token: token,
authStore: authStore,
httpClientFactory: () => MockClient((r) => handler(r)),
);
}
void main() {
group('jwt / token validity', () {
test('decodes payload', () {
expect(getTokenPayload(jwt({'id': 'u1', 'exp': 9999999999}))['id'], 'u1');
});
test('opaque api key is valid, empty is not', () {
expect(isTokenValid(''), isFalse);
expect(isTokenValid('opaque-api-key'), isTrue);
});
test('jwt exp respected', () {
expect(isTokenValid(jwt({'exp': 9999999999})), isTrue);
expect(isTokenValid(jwt({'exp': 1})), isFalse);
});
});
group('MemoryAuthStore', () {
test('isValid tracks token', () {
final store = MemoryAuthStore();
expect(store.isValid, isFalse);
store.save(jwt({'exp': 9999999999}));
expect(store.isValid, isTrue);
store.save(jwt({'exp': 1}));
expect(store.isValid, isFalse);
});
test('onChange fires and unsubscribes', () {
final store = MemoryAuthStore();
final seen = <String>[];
final off = store.onChange(seen.add);
store.save('a');
off();
store.save('b');
expect(seen, ['a']);
});
});
group('construction', () {
test('defaults to api.hanzo.ai and strips trailing slash', () {
final c = clientWith((_) async => http.Response('{}', 200));
expect(c.baseUrl, 'https://api.hanzo.ai');
final c2 = HanzoCloud(baseUrl: 'https://example.test/');
expect(c2.baseUrl, 'https://example.test');
});
test('token seeds the auth store', () {
final c = clientWith((_) async => http.Response('{}', 200), token: 'tok');
expect(c.authStore.token, 'tok');
});
});
group('bearer auth', () {
test('adds Authorization: Bearer <token>', () async {
String? seenAuth;
final c = clientWith((r) async {
seenAuth = r.headers['Authorization'];
return http.Response('{}', 200);
}, token: jwt({'exp': 9999999999}));
await c.health();
expect(seenAuth, startsWith('Bearer '));
expect(seenAuth, 'Bearer ${c.authStore.token}');
});
test('no header after signOut', () async {
String? seenAuth;
final c = clientWith((r) async {
seenAuth = r.headers['Authorization'];
return http.Response('{}', 200);
}, token: 'tok');
c.signOut();
await c.health();
expect(seenAuth, isNull);
});
});
group('agents', () {
test('list unwraps { agents }', () async {
final c = clientWith((r) async {
expect(r.url.path, '/v1/agents');
return http.Response(
jsonEncode({
'agents': [
{
'id': 'agent_1',
'name': 'a',
'tools': ['search']
}
]
}),
200);
});
final agents = await c.agents.list();
expect(agents.single.id, 'agent_1');
expect(agents.single.tools, ['search']);
});
test('create posts cleaned body', () async {
final c = clientWith((r) async {
expect(r.method, 'POST');
final body = jsonDecode(r.body) as Map;
expect(body['model'], 'zen-coder');
expect(body.containsKey('description'), isFalse); // null stripped
return http.Response(jsonEncode({'id': 'agent_2', 'name': 'w'}), 201);
});
final agent = await c.agents.create(name: 'w', model: 'zen-coder');
expect(agent.id, 'agent_2');
});
test('get encodes ref and parses detail', () async {
final c = clientWith((r) async {
expect(r.url.path, '/v1/agents/has%20space');
return http.Response(
jsonEncode({'id': 'agent_2', 'instructions': 'hi'}), 200);
});
final detail = await c.agents.get('has space');
expect(detail.instructions, 'hi');
});
test('run returns a Run', () async {
final c = clientWith((r) async {
expect(r.url.path, '/v1/agents/w/run');
expect(jsonDecode(r.body)['input'], 'go');
return http.Response(
jsonEncode({'id': 'run_1', 'status': 'ok', 'output': 'done'}), 200);
});
final run = await c.agents.run('w', input: 'go');
expect(run.status, 'ok');
expect(run.output, 'done');
});
test('runs applies limit and unwraps', () async {
final c = clientWith((r) async {
expect(r.url.queryParameters['limit'], '5');
return http.Response(
jsonEncode({
'runs': [
{'id': 'run_1'},
{'id': 'run_2'}
]
}),
200);
});
final runs = await c.agents.runs('w', limit: 5);
expect(runs.length, 2);
});
test('delete sends DELETE', () async {
var called = false;
final c = clientWith((r) async {
called = r.method == 'DELETE';
return http.Response('', 204);
});
await c.agents.delete('w');
expect(called, isTrue);
});
});
group('sessions', () {
test('register posts agent', () async {
final c = clientWith((r) async {
expect(r.url.path, '/v1/agents/sessions');
expect(jsonDecode(r.body)['agent'], 'w');
return http.Response(
jsonEncode({'id': 'sess_1', 'status': 'running'}), 201);
});
final s = await c.sessions.register(agent: 'w');
expect(s.id, 'sess_1');
});
test('list applies filters', () async {
late Uri seen;
final c = clientWith((r) async {
seen = r.url;
return http.Response(jsonEncode({'sessions': <Object>[]}), 200);
});
await c.sessions.list(status: 'running', root: 'sess_1', limit: 50);
expect(seen.queryParameters['status'], 'running');
expect(seen.queryParameters['root'], 'sess_1');
expect(seen.queryParameters['limit'], '50');
});
test('pause hits control path and parses result', () async {
final c = clientWith((r) async {
expect(r.url.path, '/v1/agents/sessions/sess_1/pause');
return http.Response(
jsonEncode({'command': 'pause', 'forwarded': true}), 200);
});
final res = await c.sessions.pause('sess_1');
expect(res.command, 'pause');
expect(res.forwarded, isTrue);
});
test('tree parses recursive nodes', () async {
final c = clientWith((r) async {
expect(r.url.path, '/v1/agents/sessions/sess_1/tree');
return http.Response(
jsonEncode({
'session': {'id': 'sess_1'},
'children': [
{
'session': {'id': 'sess_2'},
'children': <Object>[]
}
]
}),
200);
});
final tree = await c.sessions.tree('sess_1');
expect(tree.session.id, 'sess_1');
expect(tree.children.single.session.id, 'sess_2');
});
});
group('machines', () {
test('list sends pageSize + p and parses list', () async {
late Uri seen;
final c = clientWith((r) async {
seen = r.url;
return http.Response(
jsonEncode([
{'id': 'org/m1', 'name': 'm1'}
]),
200);
});
final machines = await c.machines.list(pageSize: 20, page: 2);
expect(seen.queryParameters['pageSize'], '20');
expect(seen.queryParameters['p'], '2');
expect(machines.single.name, 'm1');
});
test('add returns CloudResponse envelope', () async {
final c = clientWith((r) async {
expect(r.url.path, '/v1/cloud/add-machine');
expect(jsonDecode(r.body)['name'], 'm2');
return http.Response(jsonEncode({'status': 'ok', 'msg': ''}), 200);
});
final res = await c.machines.add(Machine(name: 'm2', region: 'sfo3'));
expect(res.ok, isTrue);
});
});
group('errors', () {
test('non-2xx throws HanzoException with status + message', () async {
final c = clientWith((r) async =>
http.Response(jsonEncode({'message': 'Insufficient credit'}), 402));
expect(
() => c.agents.run('w'),
throwsA(isA<HanzoException>()
.having((e) => e.statusCode, 'statusCode', 402)
.having((e) => e.message, 'message', 'Insufficient credit')),
);
});
});
}