toolchestrator 0.6.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.
@@ -0,0 +1,39 @@
1
+ """Toolchestrator client library.
2
+
3
+ Connects a tool running on this machine to a Toolchestrator hub:
4
+
5
+ * **Register** the tool once with a personal access token (``tcu_...``); the
6
+ tool receives its own API key (``tck_...``) which is written to a local
7
+ ``.toolchestrator.json`` config file (gitignore it!).
8
+ * **Sync** the tool's data model schemas (versioned on the hub) and records.
9
+ * **Read** other visible tools' data (filtered/projected/ordered via
10
+ ``read``), **declare connections** with a purpose (``connect_to``),
11
+ **enqueue** tasks on them (or **call** an action synchronously and get
12
+ the result back), and **subscribe** to their data changes.
13
+ * **Serve** actions: a blocking long-poll loop that claims tasks queued for
14
+ this tool and runs local handler functions (``data_handlers`` route
15
+ ``data.changed`` notifications per source tool/model).
16
+ * **Serve a web UI** (``serve_web``): tunnel a local web app's full HTTP
17
+ through the hub so visible users reach it in their browser — this machine
18
+ still makes only outbound connections.
19
+
20
+ Quick start::
21
+
22
+ from toolchestrator import Toolchestrator
23
+
24
+ # one time, writes .toolchestrator.json in the current directory:
25
+ tc = Toolchestrator.register(
26
+ "http://localhost:8787", "tcu_...", "invoice-radar", "Invoice Radar",
27
+ description="Tracks vendor invoices", sharing_scope="private")
28
+
29
+ # every run afterwards:
30
+ tc = Toolchestrator() # loads .toolchestrator.json
31
+ tc.sync_schema("invoice", schema) # JSON Schema dict
32
+ tc.sync_data("invoice", rows, id_field="id")
33
+ tc.serve({"mark_paid": mark_paid}) # blocking task loop
34
+ """
35
+
36
+ from .client import Toolchestrator, ToolchestratorError
37
+
38
+ __version__ = "0.6.0"
39
+ __all__ = ["Toolchestrator", "ToolchestratorError", "__version__"]
@@ -0,0 +1,1034 @@
1
+ """Core client for the Toolchestrator hub HTTP API.
2
+
3
+ Everything a connected tool does is outbound HTTP: push (register, schemas,
4
+ data, task results, heartbeat) and long-poll (tasks). The hub never connects
5
+ back to the tool's machine, so this client works from laptops behind NAT.
6
+
7
+ Compatible with Python 3.9+. The only third-party dependency is ``requests``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import json
14
+ import logging
15
+ import os
16
+ import threading
17
+ import time
18
+ from concurrent.futures import ThreadPoolExecutor
19
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
20
+
21
+ import requests
22
+
23
+ logger = logging.getLogger("toolchestrator")
24
+
25
+ CONFIG_FILENAME = ".toolchestrator.json"
26
+ ENV_URL = "TOOLCHESTRATOR_URL"
27
+ ENV_API_KEY = "TOOLCHESTRATOR_API_KEY"
28
+
29
+ # Handler signature: fn(payload, task) -> result dict (or None).
30
+ Handler = Callable[[Dict[str, Any], Dict[str, Any]], Optional[Dict[str, Any]]]
31
+
32
+ # data.changed routing table: (source_slug, model_name) -> handler.
33
+ DataHandlers = Dict[Tuple[str, str], Handler]
34
+
35
+ _UNSET = object() # sentinel so update() can distinguish "not passed" from None
36
+
37
+ # Hop-by-hop headers (RFC 7230 §6.1) plus request routing headers that must
38
+ # never reach the local target: the forwarded ``host`` would point at the hub,
39
+ # and ``content-length`` is recomputed by requests from the actual body.
40
+ _HOP_BY_HOP = frozenset((
41
+ "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
42
+ "te", "trailers", "transfer-encoding", "upgrade",
43
+ ))
44
+ _STRIP_REQUEST_HEADERS = _HOP_BY_HOP | frozenset(("host", "content-length"))
45
+ # On the way back, requests has already decoded the body, so length/encoding
46
+ # headers copied verbatim would misdescribe the bytes handed to the browser.
47
+ _STRIP_RESPONSE_HEADERS = _HOP_BY_HOP | frozenset(
48
+ ("content-length", "content-encoding"))
49
+
50
+
51
+ class ToolchestratorError(Exception):
52
+ """Raised for HTTP errors from the hub and client-side misconfiguration.
53
+
54
+ Attributes:
55
+ status_code: HTTP status code, or ``None`` for client-side/network
56
+ errors.
57
+ detail: the hub's ``{"detail": ...}`` error payload when available.
58
+ task_id: the task id (``task_...``) when a synchronous :meth:`call`
59
+ did not finish in time, so the caller can poll :meth:`get_task`;
60
+ ``None`` otherwise.
61
+ """
62
+
63
+ def __init__(self, message: str, status_code: Optional[int] = None,
64
+ detail: Optional[Any] = None,
65
+ task_id: Optional[str] = None):
66
+ super().__init__(message)
67
+ self.status_code = status_code
68
+ self.detail = detail
69
+ self.task_id = task_id
70
+
71
+
72
+ def _find_config(start_dir: str) -> Optional[str]:
73
+ """Walk up from ``start_dir`` looking for a ``.toolchestrator.json`` file.
74
+
75
+ Returns the absolute path of the first one found, or ``None``.
76
+ """
77
+ current = os.path.abspath(start_dir)
78
+ while True:
79
+ candidate = os.path.join(current, CONFIG_FILENAME)
80
+ if os.path.isfile(candidate):
81
+ return candidate
82
+ parent = os.path.dirname(current)
83
+ if parent == current: # reached filesystem root
84
+ return None
85
+ current = parent
86
+
87
+
88
+ class Toolchestrator:
89
+ """A connected tool's handle on the Toolchestrator hub.
90
+
91
+ Two ways to get one:
92
+
93
+ * :meth:`Toolchestrator.register` — one-time registration with a personal
94
+ access token (``tcu_...``). Creates the tool on the hub, receives the
95
+ tool's own API key (``tck_...``), and writes ``.toolchestrator.json``
96
+ in the current directory.
97
+ * ``Toolchestrator()`` — every run afterwards. Loads
98
+ ``.toolchestrator.json`` by walking up from the current working
99
+ directory; the environment variables ``TOOLCHESTRATOR_URL`` and
100
+ ``TOOLCHESTRATOR_API_KEY`` override the file's values, and explicit
101
+ constructor arguments override both.
102
+
103
+ All methods raise :class:`ToolchestratorError` on failure.
104
+ """
105
+
106
+ def __init__(self, base_url: Optional[str] = None,
107
+ api_key: Optional[str] = None,
108
+ tool_id: Optional[str] = None,
109
+ slug: Optional[str] = None,
110
+ config_path: Optional[str] = None):
111
+ """Load configuration and prepare an authenticated HTTP session.
112
+
113
+ Args:
114
+ base_url: hub URL, e.g. ``http://localhost:8787``. Optional if
115
+ present in the config file or ``TOOLCHESTRATOR_URL``.
116
+ api_key: this tool's API key (``tck_...``). Optional if present
117
+ in the config file or ``TOOLCHESTRATOR_API_KEY``.
118
+ tool_id: this tool's id (``t_...``). Normally read from the
119
+ config file.
120
+ slug: this tool's slug. Normally read from the config file.
121
+ config_path: explicit path to a ``.toolchestrator.json`` file;
122
+ when omitted the file is searched for by walking up from the
123
+ current working directory.
124
+ """
125
+ cfg: Dict[str, Any] = {}
126
+ found = config_path or _find_config(os.getcwd())
127
+ if found and os.path.isfile(found):
128
+ try:
129
+ with open(found, "r", encoding="utf-8") as fh:
130
+ cfg = json.load(fh)
131
+ except (OSError, ValueError) as exc:
132
+ raise ToolchestratorError(
133
+ "could not read config file %s: %s" % (found, exc))
134
+ self.config_path = found if cfg else None
135
+
136
+ self.base_url = (base_url or os.environ.get(ENV_URL)
137
+ or cfg.get("base_url") or "").rstrip("/")
138
+ self.api_key = (api_key or os.environ.get(ENV_API_KEY)
139
+ or cfg.get("api_key"))
140
+ self.tool_id = tool_id or cfg.get("tool_id")
141
+ self.slug = slug or cfg.get("slug")
142
+
143
+ if not self.base_url or not self.api_key:
144
+ raise ToolchestratorError(
145
+ "no Toolchestrator configuration found: expected a "
146
+ ".toolchestrator.json file in this directory or a parent "
147
+ "(created by Toolchestrator.register), or the environment "
148
+ "variables %s and %s" % (ENV_URL, ENV_API_KEY))
149
+
150
+ self._session = requests.Session()
151
+ self._session.headers["Authorization"] = "Bearer %s" % self.api_key
152
+
153
+ # ------------------------------------------------------------------ #
154
+ # Registration #
155
+ # ------------------------------------------------------------------ #
156
+
157
+ @classmethod
158
+ def register(cls, base_url: str, personal_token: str, slug: str,
159
+ name: str, description: str = "",
160
+ problem_statement: str = "", usage: str = "",
161
+ sharing_scope: str = "private",
162
+ department: Optional[str] = None,
163
+ actions: Optional[List[str]] = None,
164
+ reconnect: bool = False,
165
+ config_dir: Optional[str] = None) -> "Toolchestrator":
166
+ """Register this tool on the hub (one time) and save local config.
167
+
168
+ Uses a *personal access token* (``tcu_...``, created by the user in
169
+ the dashboard under Settings) — never a password. On success the hub
170
+ returns the tool's own API key (``tck_...``), which is written
171
+ together with the hub URL, tool id, and slug into
172
+ ``.toolchestrator.json`` in the current directory (add that file to
173
+ .gitignore — it contains a secret).
174
+
175
+ Args:
176
+ base_url: hub URL, e.g. ``http://localhost:8787``.
177
+ personal_token: the user's personal access token (``tcu_...``).
178
+ slug: URL-friendly unique-per-company identifier, e.g.
179
+ ``invoice-radar``.
180
+ name: human-readable tool name.
181
+ description: what the tool does (shown in the hub catalog).
182
+ problem_statement: the problem this tool solves and for whom.
183
+ usage: how people/tools are meant to use it.
184
+ sharing_scope: ``"private"`` (default), ``"department"``, or
185
+ ``"company"``. Start private; widen deliberately.
186
+ department: department *name* (e.g. ``"Finance"``) or ``None``.
187
+ actions: list of action names this tool can execute via tasks
188
+ (``serve`` updates this automatically from handler keys).
189
+ reconnect: if the slug already exists and you own it, pass
190
+ ``True`` to rotate the API key and update metadata instead
191
+ of failing with 409.
192
+ config_dir: directory to write ``.toolchestrator.json`` into
193
+ (defaults to the current working directory).
194
+
195
+ Returns:
196
+ A ready-to-use :class:`Toolchestrator` instance authenticated
197
+ with the new tool API key.
198
+ """
199
+ url = base_url.rstrip("/") + "/api/tools/register"
200
+ body = {
201
+ "slug": slug,
202
+ "name": name,
203
+ "description": description,
204
+ "problem_statement": problem_statement,
205
+ "usage": usage,
206
+ "sharing_scope": sharing_scope,
207
+ "department": department,
208
+ "actions": list(actions) if actions else [],
209
+ "reconnect": bool(reconnect),
210
+ }
211
+ try:
212
+ resp = requests.post(
213
+ url, json=body, timeout=30,
214
+ headers={"Authorization": "Bearer %s" % personal_token})
215
+ except requests.RequestException as exc:
216
+ raise ToolchestratorError("could not reach hub at %s: %s"
217
+ % (base_url, exc))
218
+ if resp.status_code >= 400:
219
+ raise ToolchestratorError(
220
+ "registration failed (%s): %s"
221
+ % (resp.status_code, _extract_detail(resp)),
222
+ status_code=resp.status_code, detail=_extract_detail(resp))
223
+
224
+ data = resp.json()
225
+ tool = data["tool"]
226
+ api_key = data["api_key"]
227
+
228
+ target_dir = os.path.abspath(config_dir or os.getcwd())
229
+ config_path = os.path.join(target_dir, CONFIG_FILENAME)
230
+ config = {
231
+ "base_url": base_url.rstrip("/"),
232
+ "api_key": api_key,
233
+ "tool_id": tool["id"],
234
+ "slug": tool["slug"],
235
+ }
236
+ with open(config_path, "w", encoding="utf-8") as fh:
237
+ json.dump(config, fh, indent=2)
238
+ fh.write("\n")
239
+ try:
240
+ os.chmod(config_path, 0o600) # contains a secret
241
+ except OSError:
242
+ pass
243
+
244
+ client = cls(base_url=base_url, api_key=api_key,
245
+ tool_id=tool["id"], slug=tool["slug"])
246
+ client.config_path = config_path
247
+ return client
248
+
249
+ # ------------------------------------------------------------------ #
250
+ # Schemas & data #
251
+ # ------------------------------------------------------------------ #
252
+
253
+ def sync_schema(self, model_name: str,
254
+ json_schema: Dict[str, Any]) -> Dict[str, Any]:
255
+ """Push (or confirm) the JSON Schema for one of this tool's models.
256
+
257
+ Schema versions on the hub are immutable: pushing a schema identical
258
+ to the latest version is a no-op (``changed: False``); a different
259
+ schema creates version n+1. Call this at tool startup or whenever
260
+ the local storage shape changes — before the first ``sync_data`` for
261
+ the model.
262
+
263
+ When a push creates a new version, the hub diffs it against the
264
+ previous one and returns a ``warnings`` list naming any field a
265
+ consumer of this model depends on that was removed or whose
266
+ definition changed — a soft-locked contract this push breaks (the
267
+ version is still created; the hub never blocks). Each warning is
268
+ logged (``logger.warning``) so an agent or CLI running unattended
269
+ sees it, and the full response — warnings included — is returned so
270
+ the caller can surface or act on them.
271
+
272
+ Args:
273
+ model_name: model identifier, e.g. ``"invoice"``.
274
+ json_schema: a JSON Schema dict describing one record's payload.
275
+
276
+ Returns:
277
+ ``{"model": ..., "version": int, "changed": bool,
278
+ "warnings": [str]}`` (``warnings`` empty when the push is
279
+ additive-only or the model has no dependents).
280
+ """
281
+ data = self._request(
282
+ "PUT", "/api/tools/%s/schemas/%s" % (self._self_ref(), model_name),
283
+ json_body={"json_schema": json_schema})
284
+ for warning in (data.get("warnings") or []):
285
+ logger.warning("schema %s: %s", model_name, warning)
286
+ return data
287
+
288
+ def schemas(self, tool: Optional[str] = None) -> Dict[str, Any]:
289
+ """List the versioned model schemas a tool publishes on the hub.
290
+
291
+ Inspect what models and fields already exist — this tool's own, or
292
+ (with ``tool`` set) another visible tool's — so the agent building or
293
+ editing a tool **reuses** an existing model instead of duplicating
294
+ it. Visibility is evaluated as this tool's owner.
295
+
296
+ Args:
297
+ tool: the tool's slug or id to inspect; ``None`` (default) means
298
+ this tool itself.
299
+
300
+ Returns:
301
+ ``{"models": [{"name": str, "versions": [{"version": int,
302
+ "json_schema": dict, "created_at": str}]}]}`` — newest version
303
+ first per model.
304
+ """
305
+ ref = tool if tool is not None else self._self_ref()
306
+ return self._request("GET", "/api/tools/%s/schemas" % ref)
307
+
308
+ def sync_data(self, model_name: str, records: Iterable[Dict[str, Any]],
309
+ id_field: str = "id",
310
+ deleted_ids: Optional[Iterable[str]] = None,
311
+ schema_version: Optional[int] = None) -> Dict[str, Any]:
312
+ """Upsert records for one of this tool's models on the hub.
313
+
314
+ Each record is sent whole as the payload; its stable external id is
315
+ taken from ``record[id_field]``. Re-syncing the same external id
316
+ updates the stored record (upsert), so it is safe to sync the full
317
+ current state, or only rows changed since the last sync.
318
+
319
+ Requires a schema for the model (``sync_schema``) to have been
320
+ pushed first.
321
+
322
+ Args:
323
+ model_name: model identifier, e.g. ``"invoice"``.
324
+ records: iterable of dicts; each must contain ``id_field``.
325
+ id_field: key holding each record's stable unique id
326
+ (default ``"id"``).
327
+ deleted_ids: external ids to delete from the hub, if any.
328
+ schema_version: pin records to a specific schema version;
329
+ default ``None`` uses the latest version on the hub.
330
+
331
+ Returns:
332
+ ``{"upserted": int, "deleted": int, "schema_version": int}``.
333
+ """
334
+ wire_records = []
335
+ for record in records:
336
+ if id_field not in record:
337
+ raise ToolchestratorError(
338
+ "record is missing id_field %r: %r" % (id_field, record))
339
+ wire_records.append({
340
+ "external_id": str(record[id_field]),
341
+ "payload": record,
342
+ })
343
+ body = {
344
+ "records": wire_records,
345
+ "schema_version": schema_version,
346
+ "deleted_ids": list(deleted_ids) if deleted_ids else [],
347
+ }
348
+ return self._request(
349
+ "POST",
350
+ "/api/tools/%s/data/%s/sync" % (self._self_ref(), model_name),
351
+ json_body=body)
352
+
353
+ def read(self, tool: str, model: str,
354
+ where: Optional[Dict[str, Any]] = None,
355
+ fields: Optional[List[str]] = None,
356
+ order_by: Optional[str] = None,
357
+ since: Optional[str] = None,
358
+ limit: int = 200) -> List[Dict[str, Any]]:
359
+ """Query another tool's records — filtered, projected, and ordered.
360
+
361
+ The hub does the work (SQLite ``json_extract`` over payloads), so
362
+ read only what you need instead of dumping whole models. Visibility
363
+ is evaluated as this tool's owner: private tools of other users are
364
+ not accessible, department/company scopes apply. Reading another
365
+ tool's data also registers/refreshes a **connection** on the hub
366
+ (observability, not access control) — declare its purpose with
367
+ :meth:`connect_to`.
368
+
369
+ Args:
370
+ tool: the other tool's slug or id.
371
+ model: model name, e.g. ``"invoice"``.
372
+ where: filter over top-level payload fields, AND-ed together.
373
+ A scalar value means equality; a dict value uses operators
374
+ ``$eq``, ``$ne``, ``$gt``, ``$gte``, ``$lt``, ``$lte``,
375
+ ``$in`` (list), ``$contains`` (case-insensitive substring,
376
+ strings only). Example:
377
+ ``{"status": "overdue", "amount": {"$gte": 1000}}``.
378
+ fields: top-level payload keys to keep; other keys are dropped
379
+ server-side (``_external_id`` / ``_updated_at`` always
380
+ remain).
381
+ order_by: ``"<field>"``, ``"<field>:asc"``, or
382
+ ``"<field>:desc"``; ``updated_at`` sorts on the hub column.
383
+ Default ``updated_at:desc``.
384
+ since: ISO-8601 timestamp; only records updated after it.
385
+ limit: maximum number of records to return (paginates
386
+ transparently over the server's 200-per-page cap).
387
+
388
+ Returns:
389
+ List of record payload dicts, each with the hub bookkeeping keys
390
+ ``_external_id`` and ``_updated_at`` merged in.
391
+ """
392
+ base_params: Dict[str, Any] = {}
393
+ if where is not None:
394
+ base_params["where"] = json.dumps(where)
395
+ if fields: # an empty list means "no projection", same as None
396
+ base_params["fields"] = ",".join(fields)
397
+ if order_by is not None:
398
+ base_params["order_by"] = order_by
399
+ if since is not None:
400
+ base_params["since"] = since
401
+
402
+ out: List[Dict[str, Any]] = []
403
+ offset = 0
404
+ while len(out) < limit:
405
+ page_size = min(200, limit - len(out))
406
+ params = dict(base_params)
407
+ params["limit"] = page_size
408
+ params["offset"] = offset
409
+ data = self._request(
410
+ "GET", "/api/tools/%s/data/%s" % (tool, model), params=params)
411
+ batch = data.get("records", [])
412
+ for rec in batch:
413
+ merged = dict(rec.get("payload") or {})
414
+ merged["_external_id"] = rec.get("external_id")
415
+ merged["_updated_at"] = rec.get("updated_at")
416
+ out.append(merged)
417
+ if len(batch) < page_size:
418
+ break
419
+ offset += len(batch)
420
+ return out
421
+
422
+ def fetch_data(self, tool: str, model: str, since: Optional[str] = None,
423
+ limit: int = 200) -> List[Dict[str, Any]]:
424
+ """Fetch another tool's records from the hub (visibility permitting).
425
+
426
+ Kept for compatibility; delegates to :meth:`read`, which also takes
427
+ ``where`` / ``fields`` / ``order_by`` for filtered reads.
428
+
429
+ Args:
430
+ tool: the other tool's slug or id.
431
+ model: model name, e.g. ``"invoice"``.
432
+ since: ISO-8601 timestamp; only records updated after it.
433
+ limit: maximum number of records to return (paginates
434
+ transparently over the server's 200-per-page cap).
435
+
436
+ Returns:
437
+ List of record payload dicts, each with the hub bookkeeping keys
438
+ ``_external_id`` and ``_updated_at`` merged in.
439
+ """
440
+ return self.read(tool, model, since=since, limit=limit)
441
+
442
+ # ------------------------------------------------------------------ #
443
+ # Connections #
444
+ # ------------------------------------------------------------------ #
445
+
446
+ def connect_to(self, tool: str, model: str,
447
+ purpose: Optional[str] = None) -> Dict[str, Any]:
448
+ """Declare that this tool reads ``tool``'s ``model`` — and say why.
449
+
450
+ Connections are observability, not access control: reading another
451
+ tool's data already records one automatically (``declared: false``),
452
+ but declaring it with a purpose makes the company data map explain
453
+ *why* the data flows. Idempotent upsert: calling again updates the
454
+ purpose and marks the connection declared.
455
+
456
+ Args:
457
+ tool: the source tool's slug or id (must be visible, and must
458
+ have a schema for ``model``).
459
+ model: the source model name this tool reads.
460
+ purpose: one line on why this tool reads that data, e.g.
461
+ ``"Correlate overdue invoices with at-risk accounts"``
462
+ (max 500 chars).
463
+
464
+ Returns:
465
+ The connection dict (``{"id": "conn_...", "declared": True,
466
+ "reads_count": ..., ...}``).
467
+ """
468
+ body = {
469
+ "source_tool_id": tool,
470
+ "model_name": model,
471
+ "consumer_tool_id": self._self_ref(),
472
+ "purpose": purpose,
473
+ }
474
+ return self._request("POST", "/api/connections", json_body=body)
475
+
476
+ def connections(self) -> List[Dict[str, Any]]:
477
+ """List this tool's connections (as source or consumer).
478
+
479
+ Returns:
480
+ List of connection dicts: source/consumer tool refs, model name,
481
+ purpose, ``declared`` flag, read stats (``reads_count``,
482
+ ``records_read``, ``last_read_at``).
483
+ """
484
+ data = self._request("GET", "/api/connections",
485
+ params={"tool": self._self_ref()})
486
+ return data["connections"]
487
+
488
+ def dependents(self) -> Dict[str, Any]:
489
+ """List the OTHER tools that read this tool's models — its dependents.
490
+
491
+ Field-level dependency awareness: for each model this tool publishes,
492
+ the hub reports which other tools consume it and exactly which fields
493
+ they depend on (accumulated from their reads; a whole-model reader
494
+ depends on every current field). A field a consumer reads is a
495
+ soft-locked contract — removing, renaming, or changing its type
496
+ breaks that consumer — so call this before editing this tool's data
497
+ model to see who would break.
498
+
499
+ Returns:
500
+ ``{"models": [{"model": str, "latest_version": int, "dependents":
501
+ [{"tool": {"slug": str, "name": str}, "purpose": str,
502
+ "declared": bool, "fields": [str], "all_fields": bool,
503
+ "reads_count": int, "last_read_at": str}]}]}`` — a dependent with
504
+ ``all_fields`` true depends on every current field of the model.
505
+ """
506
+ return self._request(
507
+ "GET", "/api/tools/%s/dependents" % self._self_ref())
508
+
509
+ # ------------------------------------------------------------------ #
510
+ # Discovery, tasks, metadata #
511
+ # ------------------------------------------------------------------ #
512
+
513
+ def list_tools(self, q: Optional[str] = None) -> List[Dict[str, Any]]:
514
+ """List tools on the hub visible to this tool's owner.
515
+
516
+ Args:
517
+ q: optional search over tool name/description.
518
+
519
+ Returns:
520
+ List of tool dicts (id, slug, name, description, sharing_scope,
521
+ actions, online, models, ...).
522
+ """
523
+ params = {"q": q} if q else None
524
+ return self._request("GET", "/api/tools", params=params)["tools"]
525
+
526
+ def enqueue(self, tool: str, action: str,
527
+ payload: Optional[Dict[str, Any]] = None,
528
+ timeout_s: int = 300,
529
+ max_attempts: int = 3) -> Dict[str, Any]:
530
+ """Queue a task for another (or this) tool to execute.
531
+
532
+ Execution is eventual, not live RPC: the target tool picks the task
533
+ up next time it polls (``serve``). Poll the returned task's id with
534
+ :meth:`get_task` to observe the result.
535
+
536
+ Args:
537
+ tool: target tool's slug or id (must be visible).
538
+ action: action name the target handles, e.g. ``"mark_paid"``.
539
+ payload: JSON-serializable dict passed to the target's handler.
540
+ timeout_s: seconds a claimed run may take before it is requeued
541
+ or failed (default 300).
542
+ max_attempts: total attempts before the task fails permanently
543
+ (default 3).
544
+
545
+ Returns:
546
+ The created task dict (``{"id": "task_...", "status": "queued",
547
+ ...}``).
548
+ """
549
+ body = {
550
+ "action": action,
551
+ "payload": payload or {},
552
+ "timeout_s": timeout_s,
553
+ "max_attempts": max_attempts,
554
+ }
555
+ return self._request("POST", "/api/tools/%s/tasks" % tool,
556
+ json_body=body)
557
+
558
+ def call(self, tool: str, action: str,
559
+ payload: Optional[Dict[str, Any]] = None,
560
+ timeout_s: int = 300, wait: int = 30) -> Dict[str, Any]:
561
+ """Call another (or this) tool's action synchronously and get its result.
562
+
563
+ Where :meth:`enqueue` is fire-and-forget, ``call`` queues the task and
564
+ then blocks on the hub while it long-polls internally up to ``wait``
565
+ seconds for the serving machine to finish, returning the result in one
566
+ step. The serving machine stays outbound-only — the hub is the meeting
567
+ point — so this works even for another user's shared tool behind NAT.
568
+
569
+ Args:
570
+ tool: target tool's slug or id (must be visible).
571
+ action: action name the target handles, e.g. ``"mark_paid"``.
572
+ payload: JSON-serializable dict passed to the target's handler.
573
+ timeout_s: seconds a claimed run may take before it is requeued
574
+ or failed (default 300).
575
+ wait: seconds to wait for a terminal result before giving up
576
+ (default 30; the hub clamps to 0–60, where 0 returns as soon
577
+ as the task is created).
578
+
579
+ Returns:
580
+ The action's result dict, when the task finished successfully.
581
+
582
+ Raises:
583
+ ToolchestratorError: if the handler failed (message is the
584
+ handler's error); or if the tool did not finish within
585
+ ``wait`` because it is still running or offline — then the
586
+ exception's ``task_id`` attribute carries the task id so the
587
+ caller can poll :meth:`get_task` for the eventual result.
588
+ """
589
+ body = {
590
+ "action": action,
591
+ "payload": payload or {},
592
+ "timeout_s": timeout_s,
593
+ "wait": wait,
594
+ }
595
+ data = self._request("POST", "/api/tools/%s/call" % tool,
596
+ json_body=body, timeout=wait + 15)
597
+ task = data.get("task") or {}
598
+ if data.get("finished"):
599
+ if task.get("status") == "succeeded":
600
+ result = task.get("result")
601
+ return result if result is not None else {}
602
+ raise ToolchestratorError(task.get("error") or "task failed",
603
+ task_id=task.get("id"))
604
+ state = "still running" if data.get("tool_online") else "offline"
605
+ raise ToolchestratorError(
606
+ "call to %s did not finish within %ss (%s); poll task %s with "
607
+ "get_task for the result" % (tool, wait, state, task.get("id")),
608
+ task_id=task.get("id"))
609
+
610
+ def get_task(self, task_id: str) -> Dict[str, Any]:
611
+ """Fetch one task by id (``task_...``): status, result, error, etc."""
612
+ return self._request("GET", "/api/tasks/%s" % task_id)
613
+
614
+ def heartbeat(self) -> Dict[str, Any]:
615
+ """Tell the hub this tool is alive (marks it online for ~90s).
616
+
617
+ Polling for tasks also refreshes presence, so a tool running
618
+ ``serve`` does not need explicit heartbeats.
619
+ """
620
+ return self._request(
621
+ "POST", "/api/tools/%s/heartbeat" % self._self_ref())
622
+
623
+ def update(self, name: Any = _UNSET, description: Any = _UNSET,
624
+ problem_statement: Any = _UNSET, usage: Any = _UNSET,
625
+ sharing_scope: Any = _UNSET, department: Any = _UNSET,
626
+ actions: Any = _UNSET, slug: Any = _UNSET) -> Dict[str, Any]:
627
+ """Update this tool's metadata on the hub.
628
+
629
+ Only the arguments you pass are changed; ``None`` is a real value
630
+ (e.g. ``department=None`` clears the department).
631
+
632
+ Args:
633
+ name / description / problem_statement / usage: catalog text.
634
+ sharing_scope: ``"private"``, ``"department"``, or ``"company"``.
635
+ department: department name or ``None``.
636
+ actions: list of action names this tool executes.
637
+ slug: new slug (stays unique per company).
638
+
639
+ Returns:
640
+ The updated tool dict.
641
+ """
642
+ body: Dict[str, Any] = {}
643
+ for key, value in (("name", name), ("description", description),
644
+ ("problem_statement", problem_statement),
645
+ ("usage", usage), ("sharing_scope", sharing_scope),
646
+ ("department", department), ("actions", actions),
647
+ ("slug", slug)):
648
+ if value is not _UNSET:
649
+ body[key] = value
650
+ tool = self._request("PATCH", "/api/tools/%s" % self._self_ref(),
651
+ json_body=body)
652
+ if isinstance(tool, dict) and tool.get("slug"):
653
+ self.slug = tool["slug"]
654
+ return tool
655
+
656
+ def subscribe(self, source_tool: str, model: str) -> Dict[str, Any]:
657
+ """Subscribe this tool to another tool's data changes.
658
+
659
+ After the source tool syncs changes to ``model``, the hub queues a
660
+ ``data.changed`` task for this tool with payload
661
+ ``{"source_tool": <slug>, "model_name", "upserted", "deleted"}`` —
662
+ handle it in ``serve`` with a ``"data.changed"`` handler key.
663
+
664
+ Args:
665
+ source_tool: the source tool's slug or id (must be visible).
666
+ model: the source model name to watch.
667
+
668
+ Returns:
669
+ The created subscription dict. Raises with status 409 if the
670
+ subscription already exists.
671
+ """
672
+ body = {
673
+ "source_tool_id": source_tool,
674
+ "model_name": model,
675
+ "subscriber_tool_id": self._self_ref(),
676
+ }
677
+ return self._request("POST", "/api/subscriptions", json_body=body)
678
+
679
+ # ------------------------------------------------------------------ #
680
+ # Serving tasks #
681
+ # ------------------------------------------------------------------ #
682
+
683
+ def serve(self, handlers: Dict[str, Handler],
684
+ data_handlers: Optional[DataHandlers] = None,
685
+ poll_wait: int = 20) -> None:
686
+ """Blocking loop: poll the hub for tasks and run local handlers.
687
+
688
+ On start, PATCHes this tool's ``actions`` to the handler keys so the
689
+ hub (and its dashboard) advertise exactly what this tool can do.
690
+ Then long-polls forever, claiming one task at a time and reporting
691
+ its result. Stop with Ctrl-C (KeyboardInterrupt propagates).
692
+
693
+ Handler contract — ``fn(payload: dict, task: dict) -> dict | None``:
694
+
695
+ * return a dict (or ``None``) → task ``succeeded`` with that result;
696
+ * raise any exception → task ``failed`` with the exception message
697
+ (the hub retries while attempts remain);
698
+ * a polled action with no handler → task ``failed`` with
699
+ ``"no handler for <action>"``.
700
+
701
+ ``data.changed`` tasks (subscription notifications) are routed
702
+ through ``data_handlers`` first, by
703
+ ``(payload["source_tool"], payload["model_name"])``; an unmatched
704
+ one falls back to ``handlers["data.changed"]`` if present, else
705
+ fails with the normal no-handler error.
706
+
707
+ Network errors are logged and retried after a short sleep; the loop
708
+ never exits on its own.
709
+
710
+ Args:
711
+ handlers: mapping of action name → handler function. Include a
712
+ ``"data.changed"`` key to receive subscription
713
+ notifications not matched by ``data_handlers``.
714
+ data_handlers: mapping of ``(source_slug, model_name)`` →
715
+ handler for that source's ``data.changed`` notifications.
716
+ poll_wait: long-poll wait in seconds per request (server caps
717
+ at 25).
718
+ """
719
+ actions = set(handlers.keys())
720
+ if data_handlers:
721
+ actions.add("data.changed")
722
+ self.update(actions=sorted(actions))
723
+ logger.info("serving actions %s for tool %s",
724
+ sorted(actions), self.slug or self.tool_id)
725
+ while True:
726
+ try:
727
+ self.serve_once(handlers, data_handlers=data_handlers,
728
+ wait=poll_wait)
729
+ except ToolchestratorError as exc:
730
+ logger.warning("poll failed (%s); retrying in 3s", exc)
731
+ time.sleep(3)
732
+
733
+ def serve_once(self, handlers: Dict[str, Handler],
734
+ data_handlers: Optional[DataHandlers] = None,
735
+ wait: int = 5) -> int:
736
+ """Single poll: claim up to one task, execute it, report the result.
737
+
738
+ Same handler contract and ``data.changed`` routing as :meth:`serve`.
739
+ Useful in tests and for tools that check for work on their own
740
+ schedule (e.g. from cron).
741
+
742
+ Args:
743
+ handlers: mapping of action name → handler function.
744
+ data_handlers: mapping of ``(source_slug, model_name)`` →
745
+ handler for that source's ``data.changed`` notifications.
746
+ wait: long-poll wait in seconds (0 returns immediately if no
747
+ task is queued).
748
+
749
+ Returns:
750
+ Number of tasks handled (0 or 1).
751
+ """
752
+ data = self._request(
753
+ "GET", "/api/tools/%s/tasks/poll" % self._self_ref(),
754
+ params={"wait": wait, "max": 1}, timeout=wait + 15)
755
+ tasks = data.get("tasks", [])
756
+ for task in tasks:
757
+ self._execute_task(handlers, task, data_handlers=data_handlers)
758
+ return len(tasks)
759
+
760
+ def _execute_task(self, handlers: Dict[str, Handler],
761
+ task: Dict[str, Any],
762
+ data_handlers: Optional[DataHandlers] = None) -> None:
763
+ """Run the handler for one claimed task and post its result."""
764
+ task_id = task["id"]
765
+ action = task.get("action")
766
+ payload = task.get("payload") or {}
767
+ handler: Optional[Handler] = None
768
+ if action == "data.changed" and data_handlers:
769
+ handler = data_handlers.get(
770
+ (payload.get("source_tool"), payload.get("model_name")))
771
+ if handler is None and action is not None:
772
+ handler = handlers.get(action)
773
+ if handler is None:
774
+ self._post_result(task_id, "failed",
775
+ error="no handler for %s" % action)
776
+ return
777
+ try:
778
+ result = handler(payload, task)
779
+ except Exception as exc: # noqa: BLE001 — any handler error fails the task
780
+ message = str(exc) or exc.__class__.__name__
781
+ logger.warning("handler for %s raised: %s", action, message)
782
+ self._post_result(task_id, "failed", error=message)
783
+ else:
784
+ self._post_result(task_id, "succeeded",
785
+ result=result if result is not None else {})
786
+
787
+ def _post_result(self, task_id: str, status: str,
788
+ result: Optional[Dict[str, Any]] = None,
789
+ error: Optional[str] = None) -> Dict[str, Any]:
790
+ """Report a claimed task's outcome to the hub."""
791
+ if status == "succeeded":
792
+ body: Dict[str, Any] = {"status": "succeeded",
793
+ "result": result if result is not None else {}}
794
+ else:
795
+ body = {"status": "failed", "error": error or "unknown error"}
796
+ return self._request("POST", "/api/tasks/%s/result" % task_id,
797
+ json_body=body)
798
+
799
+ # ------------------------------------------------------------------ #
800
+ # Serving a web UI (app tunnel) #
801
+ # ------------------------------------------------------------------ #
802
+
803
+ def serve_web(self, target: str, workers: int = 8) -> None:
804
+ """Blocking loop: tunnel a local web app's HTTP through the hub.
805
+
806
+ Where :meth:`serve` tunnels named *actions*, ``serve_web`` tunnels full
807
+ *HTTP*, so a tool that runs a local web UI (e.g. a Flask app on
808
+ ``http://127.0.0.1:5000``) can be reached through the hub by any user
809
+ who can see the tool. This machine stays outbound-only: it opens a
810
+ single **outbound WebSocket** to the hub, receives forwarded browser
811
+ requests over it, replays each one against ``target``, and sends the
812
+ response back over the same socket — the hub is the only party
813
+ accepting inbound (browser) traffic.
814
+
815
+ Requests are handled concurrently in a small thread pool so a slow
816
+ response cannot stall the others; every send back over the shared
817
+ socket is serialized by a lock. Run this alongside :meth:`serve` in a
818
+ separate thread or process if the tool serves both actions and a UI.
819
+
820
+ **SSRF containment.** The outbound URL is built *strictly* from the
821
+ fixed local ``target`` plus the forwarded path and query — the hub
822
+ never sends, and this client never reads, any request-supplied host, so
823
+ a forwarded request can only ever reach ``target``. Paths containing
824
+ ``..`` are refused with a 400 without touching the target. The local
825
+ target also never sees the hub credentials: forwarding uses a plain
826
+ ``requests`` call, not this client's authenticated session.
827
+
828
+ The WebSocket is reconnected with exponential backoff on any drop, like
829
+ :meth:`serve`; the loop never exits on its own (stop with Ctrl-C).
830
+
831
+ Args:
832
+ target: base URL of the local web app, e.g.
833
+ ``"http://127.0.0.1:5000"`` (any path/query on it is ignored —
834
+ only the scheme/host/port are used).
835
+ workers: size of the thread pool forwarding requests concurrently.
836
+ """
837
+ try:
838
+ import websocket # type: ignore
839
+ except ImportError as exc: # pragma: no cover — dependency is declared
840
+ raise ToolchestratorError(
841
+ "serve_web needs the 'websocket-client' package; install it "
842
+ "with: pip install 'websocket-client>=1.5'") from exc
843
+
844
+ target = target.rstrip("/")
845
+ ws_url = self._tunnel_ws_url()
846
+ header = ["Authorization: Bearer %s" % self.api_key]
847
+ logger.info("tunnelling web UI at %s through tool %s (workers=%d)",
848
+ target, self.slug or self.tool_id, workers)
849
+ executor = ThreadPoolExecutor(max_workers=workers)
850
+ backoff = 1.0
851
+ try:
852
+ while True:
853
+ try:
854
+ ws = websocket.create_connection(ws_url, header=header)
855
+ except Exception as exc: # noqa: BLE001 — any connect error retries
856
+ logger.warning("tunnel connect failed (%s); retrying in "
857
+ "%.0fs", exc, backoff)
858
+ time.sleep(backoff)
859
+ backoff = min(backoff * 2, 30.0)
860
+ continue
861
+ backoff = 1.0 # clean connect resets the backoff
862
+ send_lock = threading.Lock()
863
+ logger.info("tunnel websocket connected for tool %s",
864
+ self.slug or self.tool_id)
865
+ try:
866
+ while True:
867
+ message = ws.recv()
868
+ if message is None or message == "":
869
+ break # server closed the socket
870
+ try:
871
+ req = json.loads(message)
872
+ except (ValueError, TypeError):
873
+ continue # ignore malformed frames
874
+ executor.submit(self._forward_web_request,
875
+ target, ws, send_lock, req)
876
+ except Exception as exc: # noqa: BLE001 — any drop reconnects
877
+ logger.warning("tunnel websocket dropped (%s); "
878
+ "reconnecting in %.0fs", exc, backoff)
879
+ finally:
880
+ try:
881
+ ws.close()
882
+ except Exception: # noqa: BLE001 — best-effort close
883
+ pass
884
+ time.sleep(backoff)
885
+ backoff = min(backoff * 2, 30.0)
886
+ finally:
887
+ executor.shutdown(wait=False)
888
+
889
+ def _tunnel_ws_url(self) -> str:
890
+ """Build the tunnel WebSocket URL from the hub base URL.
891
+
892
+ Derives the scheme from ``self.base_url`` (``http`` → ``ws``,
893
+ ``https`` → ``wss``) and appends this tool's tunnel path.
894
+ """
895
+ base = self.base_url
896
+ if base.startswith("https://"):
897
+ ws_base = "wss://" + base[len("https://"):]
898
+ elif base.startswith("http://"):
899
+ ws_base = "ws://" + base[len("http://"):]
900
+ else: # already ws://, wss://, or scheme-less — use as-is
901
+ ws_base = base
902
+ return ws_base + "/api/tools/%s/tunnel/ws" % self._self_ref()
903
+
904
+ def _forward_web_request(self, target: str, ws: Any,
905
+ send_lock: "threading.Lock",
906
+ req: Dict[str, Any]) -> None:
907
+ """Replay one forwarded request against ``target`` and send the reply.
908
+
909
+ Runs in the thread pool. Any failure reaching the local target is
910
+ turned into a 502 so the waiting browser request resolves instead of
911
+ hanging until the hub's timeout. The response frame is sent back over
912
+ the shared ``ws`` under ``send_lock`` (many workers share one socket).
913
+ """
914
+ req_id = req.get("req_id")
915
+ if not req_id:
916
+ return
917
+ try:
918
+ status, headers, body = self._perform_web_forward(target, req)
919
+ except Exception as exc: # noqa: BLE001 — any error becomes a 502 reply
920
+ logger.warning("forwarding request %s failed: %s", req_id, exc)
921
+ status = 502
922
+ headers = {"Content-Type": "text/plain; charset=utf-8"}
923
+ body = ("tunnel target error: %s" % exc).encode("utf-8")
924
+ self._send_tunnel_response(ws, send_lock, req_id, status, headers, body)
925
+
926
+ def _perform_web_forward(self, target: str, req: Dict[str, Any]
927
+ ) -> Tuple[int, Dict[str, str], bytes]:
928
+ """Send one forwarded request to the local target; return its reply.
929
+
930
+ Returns ``(status, headers, body_bytes)``. The outbound URL is built
931
+ strictly as ``target + path + ("?" + query)`` — never from any
932
+ request-supplied host — and ``..`` paths are refused (400) up front.
933
+ """
934
+ path = req.get("path") or "/"
935
+ if ".." in path:
936
+ return (400, {"Content-Type": "text/plain; charset=utf-8"},
937
+ b"refused: path contains '..'")
938
+ if not path.startswith("/"):
939
+ path = "/" + path
940
+ query = req.get("query") or ""
941
+ url = target + path + (("?" + query) if query else "")
942
+ method = (req.get("method") or "GET").upper()
943
+ headers = _filter_headers(req.get("headers") or {},
944
+ _STRIP_REQUEST_HEADERS)
945
+ body_b64 = req.get("body_b64")
946
+ body = base64.b64decode(body_b64) if body_b64 else None
947
+ # A plain requests call (not self._session) so the hub credentials are
948
+ # never sent to the local target; no redirect following so the browser
949
+ # sees 3xx responses itself.
950
+ resp = requests.request(method, url, headers=headers, data=body,
951
+ timeout=30, allow_redirects=False)
952
+ resp_headers = _filter_headers(resp.headers, _STRIP_RESPONSE_HEADERS)
953
+ return (resp.status_code, resp_headers, resp.content)
954
+
955
+ def _send_tunnel_response(self, ws: Any, send_lock: "threading.Lock",
956
+ req_id: str, status: int,
957
+ headers: Dict[str, str], body: bytes) -> None:
958
+ """Send one tunnelled response frame back over the socket (best-effort).
959
+
960
+ Serialized by ``send_lock`` because worker threads share one socket.
961
+ """
962
+ frame = {
963
+ "req_id": req_id,
964
+ "status": int(status),
965
+ "headers": dict(headers),
966
+ "body_b64": base64.b64encode(body or b"").decode("ascii"),
967
+ }
968
+ message = json.dumps(frame)
969
+ try:
970
+ with send_lock:
971
+ ws.send(message)
972
+ except Exception as exc: # noqa: BLE001 — a dead socket reconnects in the loop
973
+ logger.warning("sending tunnel response for %s failed: %s",
974
+ req_id, exc)
975
+
976
+ # ------------------------------------------------------------------ #
977
+ # Internals #
978
+ # ------------------------------------------------------------------ #
979
+
980
+ def _self_ref(self) -> str:
981
+ """This tool's identifier for URL paths (id preferred, else slug)."""
982
+ ref = self.tool_id or self.slug
983
+ if not ref:
984
+ raise ToolchestratorError(
985
+ "this client has no tool identity (tool_id/slug): load it "
986
+ "from a .toolchestrator.json written by "
987
+ "Toolchestrator.register, or pass tool_id= explicitly")
988
+ return ref
989
+
990
+ def _request(self, method: str, path: str,
991
+ json_body: Optional[Dict[str, Any]] = None,
992
+ params: Optional[Dict[str, Any]] = None,
993
+ timeout: int = 30) -> Any:
994
+ """Send one authenticated request; raise ToolchestratorError on failure."""
995
+ url = self.base_url + path
996
+ try:
997
+ resp = self._session.request(method, url, json=json_body,
998
+ params=params, timeout=timeout)
999
+ except requests.RequestException as exc:
1000
+ raise ToolchestratorError("request to %s failed: %s" % (url, exc))
1001
+ if resp.status_code >= 400:
1002
+ detail = _extract_detail(resp)
1003
+ raise ToolchestratorError(
1004
+ "%s %s -> %s: %s" % (method, path, resp.status_code, detail),
1005
+ status_code=resp.status_code, detail=detail)
1006
+ if resp.status_code == 204 or not resp.content:
1007
+ return None
1008
+ return resp.json()
1009
+
1010
+
1011
+ def _filter_headers(headers: Any, drop: "frozenset") -> Dict[str, str]:
1012
+ """Copy ``headers`` (dict or requests CaseInsensitiveDict), dropping any
1013
+ whose lower-cased name is in ``drop``. Values are coerced to ``str``."""
1014
+ out: Dict[str, str] = {}
1015
+ try:
1016
+ items = headers.items()
1017
+ except AttributeError:
1018
+ items = []
1019
+ for name, value in items:
1020
+ if str(name).lower() in drop:
1021
+ continue
1022
+ out[str(name)] = str(value)
1023
+ return out
1024
+
1025
+
1026
+ def _extract_detail(resp: "requests.Response") -> Any:
1027
+ """Best-effort extraction of the hub's ``{"detail": ...}`` error body."""
1028
+ try:
1029
+ body = resp.json()
1030
+ if isinstance(body, dict) and "detail" in body:
1031
+ return body["detail"]
1032
+ return body
1033
+ except ValueError:
1034
+ return resp.text[:500]
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: toolchestrator
3
+ Version: 0.6.0
4
+ Summary: Connect a local tool to a Toolchestrator hub: registry, versioned schema/data sync, task serving, cross-tool reads/calls, and web-UI tunnelling.
5
+ Author-email: Stevica Kuharski <kstevica@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://toolchestrator.com
8
+ Project-URL: Connect guide, https://hub.toolchestrator.com/skill
9
+ Keywords: toolchestrator,orchestration,internal-tools,claude-code,ai-tools,automation
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Classifier: Topic :: System :: Distributed Computing
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: requests>=2.25
25
+ Requires-Dist: websocket-client>=1.5
26
+ Dynamic: license-file
27
+
28
+ # toolchestrator
29
+
30
+ The Python client for [Toolchestrator](https://toolchestrator.com) — connect a small local
31
+ tool to your company's hub without moving it off the machine it runs on.
32
+
33
+ With this client a connected tool can:
34
+
35
+ - register itself in the company **registry** (name, author, the problem it solves);
36
+ - publish **versioned JSON-Schema** data models and **sync records** to the hub;
37
+ - **serve remote task actions** over an outbound long-poll (the hub never connects into your
38
+ machine);
39
+ - **read other tools' data** through the hub with filtered queries;
40
+ - **invoke** another tool's action and get the result back (`tc.call`);
41
+ - **expose its own local web UI** through the hub (`tc.serve_web`).
42
+
43
+ Works on Python 3.9+; depends only on `requests` and `websocket-client`.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install toolchestrator
49
+ ```
50
+
51
+ ## Quick start
52
+
53
+ ```python
54
+ from toolchestrator import Toolchestrator
55
+
56
+ # one-time registration, with a personal token from your hub's Settings page
57
+ tc = Toolchestrator.register(
58
+ "https://hub.example.com", # your company's hub URL
59
+ "tcu_...", # personal access token
60
+ "invoice-radar", "Invoice Radar",
61
+ description="Tracks vendor invoices and flags overdue ones.",
62
+ sharing_scope="private", # start private; widen deliberately
63
+ )
64
+
65
+ # afterwards, anywhere in the tool (loads .toolchestrator.json):
66
+ tc = Toolchestrator()
67
+ tc.sync_schema("invoice", invoice_schema)
68
+ tc.sync_data("invoice", rows, id_field="id")
69
+ tc.serve({"mark_paid": handle_mark_paid}) # execute tasks queued on the hub
70
+ ```
71
+
72
+ The easiest way to connect a tool is to let an AI coding agent do the wiring: point it at
73
+ your hub's connect guide (`<your-hub-url>/skill`) and ask it to *"connect this tool to
74
+ Toolchestrator"*. The hub URL always comes from you — the client never assumes one.
75
+
76
+ ## License
77
+
78
+ MIT © Stevica Kuharski
@@ -0,0 +1,7 @@
1
+ toolchestrator/__init__.py,sha256=EfvYag-kebRTGsS3uaSqBZmfLaF2k-EXXspExkG7JAI,1793
2
+ toolchestrator/client.py,sha256=LPI6FDFFzHHwj9CeK1HexMAVkDxImWDBhxp4XqLAI0Q,47568
3
+ toolchestrator-0.6.0.dist-info/licenses/LICENSE,sha256=ZH09wJV0YqfGR3_U7gXGsgaYOYT2eTYFbe7_Ev288gs,1073
4
+ toolchestrator-0.6.0.dist-info/METADATA,sha256=HiAa7K2v7Yi5DGvgc9SeZHZzl0j2JOCxHd8NUGcYrOE,3078
5
+ toolchestrator-0.6.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
6
+ toolchestrator-0.6.0.dist-info/top_level.txt,sha256=0XqBTq-CCPtZXfrFFGkdRtnixmm7v035Q88F6UE65-Y,15
7
+ toolchestrator-0.6.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stevica Kuharski
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ toolchestrator