Metadata-Version: 2.3
Name: kindgi
Version: 0.4.0
Summary: Python SDK for Kindgi™ — write a pack's tools and guardrail checks in Python, run them in a Kindgi pack service, and call the Kindgi API.
Project-URL: Homepage, https://github.com/kindgi/kindgi-sdk/tree/main/sdks/python#readme
Project-URL: Repository, https://github.com/kindgi/kindgi-sdk
Project-URL: Issues, https://github.com/kindgi/kindgi-sdk/issues
Project-URL: Changelog, https://github.com/kindgi/kindgi-sdk/blob/main/sdks/python/CHANGELOG.md
Author: Kindgi Inc.
License: Apache-2.0
Keywords: agents,ai,kindgi,tools,workflows
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: httpx<1,>=0.27
Requires-Dist: jsonschema<5,>=4.18
Requires-Dist: pydantic<3,>=2.10
Requires-Dist: uvicorn<1,>=0.27
Provides-Extra: verify
Requires-Dist: cryptography>=50.0.0; extra == 'verify'
Description-Content-Type: text/markdown

# `kindgi` — Kindgi™ for Python

Write a Kindgi pack's **tools** and **guardrail checks** in Python — agents
and flows are data, declared next to them — and call the Kindgi API from
Python. The Kindgi runtime calls your code over
[pack protocol v2](../../packages/specs/schemas/pack-protocol.schema.json) —
the contract its Node pack service speaks, checked by the same
[conformance suite](../../packages/pack-conformance/).

```sh
uv add kindgi        # or: pip install kindgi
```

Requires Python 3.11+. Dependencies: pydantic ≥ 2.10, uvicorn ≥ 0.27, httpx ≥
0.27, jsonschema ≥ 4.18 — CI runs the tests and the pack conformance suite at
those minimums as well as at the lock.

## A pack

```
ledger/
├── pyproject.toml          # [tool.kindgi] — the pack's id and version
├── tools/ledger.py         # @tool
├── tools/_db.py            # a helper (a leading `_`: not a primitive)
├── guardrails/response.py  # @guardrail
├── agents/bookkeeper.py    # Agent(...)
└── flows/record.py         # Flow(...)
```

```toml
# pyproject.toml
[tool.kindgi.pack]
id = "acme.ledger"
version = "1.0.0"
```

The `[tool.kindgi]` table takes the keys `kindgi.config.ts` takes (`pack`,
`discovery`, `dev`, `env`, `environments`, …), spelled the same.

A pack declares the process env its code and libraries read from
`os.environ`, names only; the index carries them:

```toml
[tool.kindgi.env]
required = ["DATABASE_URL"]   # unset or "" → the pack service isn't ready
optional = ["SENTRY_DSN"]     # read when set
```

`KINDGI_*` names configure Kindgi itself and can't be declared.

### Tools

```python
# tools/ledger.py
import os

from pydantic import BaseModel, Field

from kindgi import ToolContext, tool

from ._db import insert_expense


class Expense(BaseModel):
    vendor: str = Field(min_length=1)
    amount_cents: int = Field(alias="amountCents", ge=0)


class Recorded(BaseModel):
    expense_id: str = Field(alias="expenseId")


@tool(id="acme.ledger.record-expense", effects=[{"kind": "writes", "resource": "db:ledger"}])
def record_expense(expense: Expense, ctx: ToolContext) -> Recorded:
    """Records an expense in the ledger."""
    expense_id = insert_expense(os.environ["LEDGER_DATABASE_URL"], ctx.tenant_id, expense)
    return Recorded(expenseId=expense_id)
```

- The input and output schemas come from the handler's annotations — a
  pydantic model, a `TypedDict`, a dataclass, anything pydantic understands —
  or from `input=` / `output=` (a type, or a JSON Schema dict). A dict is
  checked when the tool is defined, as the runtime compiles it, strictly: one
  the runtime would refuse raises `DefinitionError`. So does a `pattern` only
  JavaScript reads, such as `\p{L}`: the SDK checks data against it with
  Python's `re`. A schema derived from a type is compiled the same way, so a
  keyword `json_schema_extra` adds, or a `format` the runtime doesn't know (a
  `Path` field's `"format": "path"`, `UUID4`'s `"uuid4"`), raises it too; a
  plain `uuid.UUID` or `str`, or pydantic's `WithJsonSchema`, gives such a
  field a schema the runtime compiles. In a derived schema, a `\p{L}`
  pattern doesn't raise: pydantic checks data against it, and reads it.
  Field aliases are the names on the wire.
  The input is an object: a model calls a tool with an object of arguments.
- The description is the docstring (or `description=`); the version is the
  pack's (or `version=`, an exact semver).
- `mutating=False` declares a tool read-only: it runs in a dry run, and an
  agent with tool approval gates on (and no override or `default` for it)
  doesn't ask before it. Without it a tool may change something (as with
  `mutating=True`).
- `needs_approval_when_unattended=True` makes each call wait for a person's
  approval in a run no person started (a webhook, a schedule, an event, or a
  service account's key). A flow's tool step that calls it is refused there.
  A webhook trigger or a schedule whose `toolsAllowedUnattended` names the
  tool (a project admin's to set) lifts that, in its own runs only. A run a
  person starts is unaffected.
- `result_is_outside_data=True` says the tool's results are text from outside
  that nobody checked, such as a mail someone sent or a page a search found.
  An agent reads each result of the tool as data, not instructions, in that
  turn and in every later turn's history. It changes what the model is told,
  not what the tool may do: pair it with approvals for anything an agent could
  do with what it read. Absent means `false`.
- The handler is `(input)` or `(input, ctx)`, `def` or `async def`. A `def`
  handler runs in a worker thread, so blocking I/O is fine.
- Before calling you, the pack service validates the input against the schema
  and runs your model's own validators; it validates what you return, too.
- `ctx` carries `tenant_id`, `run_id`, `request_id` and `cancellation`, and in
  a run `project_id` (the run's project) and `org_id` (its org, or `None`),
  which the runtime sets from the run, never from the input.
  `ctx.secrets` holds the secrets the tool declares
  (`needs_spec={"secrets": {"CITATOR_KEY": {"type": "string"}}}`): the
  runtime resolves them on every call, for the call's tenant, in its env
  (`KINDGI_ENV`; in `kindgi dev`, `local` — the pack's `.env` and
  `.env.local`, then Kindgi's own `.kindgi/secrets.env`), and fails the call, naming the secret, when one is missing.
  `ctx.env` and `ctx.config` are reserved and still empty: read other
  configuration from the process environment — the pack service's, which in
  `kindgi dev` is the pack's `.env` and `.env.local`.

**Cancellation.** When a call passes its deadline or the caller disconnects,
the runtime gets `deadline-exceeded` / `cancelled` at once and
`ctx.cancellation` fires. An `async def` handler is also cancelled at its next
`await` (`asyncio.CancelledError`). A `def` handler keeps running in its
thread — check `ctx.cancellation.cancelled`, or wait on it
(`ctx.cancellation.wait(timeout)`), between slow steps.

**Output.** `print()` and logging go to the pack service's own stdout and
stderr (`kindgi dev` shows them as `[pack] …`), never into a response.

**HTTP tools.** A tool that is one HTTP request needs no handler:
`http_tool(...)` declares it, and the Kindgi runtime makes the request (the
counterpart of TypeScript's `defineTool({ spec: { kind: 'http' } })`).

```python
from kindgi import http_tool

lookup_vendor = http_tool(
    id="acme.ledger.lookup-vendor",
    description="Looks a vendor up in the vendor registry.",
    input=VendorRef,
    output=Vendor,
    method="GET",
    url_template="https://vendors.example.com/v1/{vendor_id}",
    authorization={"kind": "bearer", "secretRef": {"envName": "local", "name": "VENDORS_TOKEN"}},
)
```

`{name}` placeholders come from the input's fields (each must be one). The
runtime resolves the secret per call, and the spec (`headers`,
`request_body`, `timeout_ms`, `success_status`, …) is checked where it's
declared, against the same schema as TypeScript's. Calling it in Python
raises: it runs in Kindgi.

### Guardrail checks

```python
# guardrails/response.py
from pydantic import BaseModel, Field

from kindgi import CheckResult, RunTrace, guardrail


class Config(BaseModel):
    min_length: int = Field(default=1, alias="minLength", ge=0)


@guardrail(id="acme.ledger.response-not-empty", on_violation="halt", severity="error")
def response_not_empty(config: Config, trace: RunTrace) -> CheckResult:
    text = (trace.output or "").strip()
    if len(text) < config.min_length:
        return CheckResult(passed=False, reason=f"Response too short ({len(text)} chars)")
    return CheckResult(passed=True)
```

A check is `(config, trace)` and returns a `CheckResult`, a dict with a
boolean `passed`, or a bool. Its config type is the first parameter's
annotation (or `config_type=`); `config=` on the decorator is what it runs
with, keyed as on the wire (`config={"minLength": 20}`), checked against the
type here — without it the check gets `{}`, its defaults. `RunTrace` holds the run's `output`,
`tool_calls`, `tool_results`, `model_calls`, … (snake_case here, camelCase on
the wire). `on_violation` names the action (`halt`, `retry`, `escalate`,
`log-only`, `compensate`); `action=` takes the whole object
(`{"on-violation": "retry", "retry": {"maxAttempts": 2}}`). The runtime acts
on `halt`: the turn fails with `guardrail-violation`. It acts on `retry` too:
the answer goes back to the model with the check's reason, for a new one,
until the guardrail has failed `maxAttempts` answers, and then the turn fails
the same way. Any other action is recorded on the turn's violations and the
turn completes; in 0.1 nothing escalates or calls a compensating tool.

### Agents and flows

```python
# agents/bookkeeper.py
from kindgi import Agent

from ..guardrails.response import response_not_empty
from ..tools.ledger import record_expense

bookkeeper = Agent(
    id="acme.ledger.bookkeeper",
    version="1.0.0",
    name="Bookkeeper",
    instructions="Record each expense the user describes with acme.ledger.record-expense.",
    capabilities=[{"needs": [{"feature": "tool-use"}]}],
    tools=[record_expense],  # Tool objects, or {"id", "version"} refs
    guardrails=[response_not_empty],
)
```

```python
# flows/record.py
from kindgi import Flow

from ..tools.ledger import record_expense

record = Flow(
    id="acme.ledger.record-flow",
    version="1.0.0",
    nodes=[{"id": "record", "kind": "tool", "ref": record_expense}],
    edges=[
        {"id": "e-start", "from": "$start", "to": "record"},
        {"id": "e-end", "from": "record", "to": "$end"},
    ],
)
```

Flows follow [`flow.schema.json`](../../packages/specs/schemas/flow.schema.json);
the indexer checks them against it.

## Imports inside a pack

Each file is imported as part of one package rooted at the pack, so relative
imports work (`from ._db import …`, `from ..tools.ledger import …`), and a
folder named `tools/` or `agents/` never collides with an installed
distribution. The pack root is also on `sys.path`, so an application's own
packages import by name — a pack can live inside an existing app:

```toml
[tool.kindgi.discovery]
tools = "kindgi/tools/**/*.py"
guardrails = "kindgi/guardrails/**/*.py"
agents = "kindgi/agents/**/*.py"
flows = "kindgi/flows/**/*.py"
```

Every module under a discovery folder defines at least one primitive at
module level; helper modules start with `_`. Tests (`test_*.py`, `*_test.py`,
`conftest.py`) are skipped.

## Running it

`kindgi dev` indexes the pack, runs it in a local pack service with the pack's
own interpreter and environment, and swaps the code on every save. Underneath
are two commands you can run yourself:

```sh
python -m kindgi.pack index --pack-dir .                         # → index.json
KINDGI_PACK_SERVICE_TOKEN=… python -m kindgi.pack serve --index index.json
```

`serve` has the Node pack service's process contract: `--index`,
`--module-root` and `--host`; `KINDGI_PACK_SERVICE_TOKEN`, `PORT`,
`KINDGI_PACK_SERVICE_MAX_CONCURRENCY`, `KINDGI_PACK_ENV_CHECK` and
`KINDGI_PACK_ENV_FILTER`; JSON log lines on stderr; SIGTERM drains in-flight
calls for up to 8 s.

Before the pack's code loads, it drops from its environment every variable
the pack doesn't declare (`[tool.kindgi.env]`), except `KINDGI_*` and the
platform's (`kindgi.pack.env_filter`: the process's basics, the language
runtime's settings, `PORT`, proxies and certificates, and Cloud Run's, AWS's
and Azure's workload identity and metadata), and logs their names once
(`env-dropped`), never their values. `KINDGI_PACK_ENV_FILTER=off` keeps
every variable; `kindgi dev` sets it.

At startup it checks the index's `env.required`. Under
`KINDGI_PACK_ENV_CHECK=strict` (the default), a name that is unset or `""`
holds `/readyz` and every call at 503 `{"error": "missing env",
"missingEnv": [...]}`; under `warn` it serves. Either way the names are
logged once (`{"kind": "missing-env", …}`) and `/v1/info` lists them in
`missingEnv`.

## Calling the Kindgi API

`kindgi.client` covers every operation of the API — generated from
[`openapi.json`](../../packages/api/openapi.json), so it can't fall behind it.
An operation id `approvals.reviewers.list` is `client.approvals.reviewers.list()`.

```python
from kindgi.client import GuardrailViolationError, Kindgi, paginate

client = Kindgi()  # KINDGI_API_URL, KINDGI_API_TOKEN — or Kindgi(url, token=…)

run = client.runs.start(
    flow="acme.ledger.record-flow", input={"vendor": "Acme", "amountCents": 1299}
)
print(run.status, run.output)

try:
    turn = client.runs.start(agent="acme.ledger.bookkeeper", input={"userMessage": "Lunch, $12.99"})
except GuardrailViolationError as blocked:
    print(blocked.violations)

for event in client.runs.follow(turn.id):  # SSE, to the run's end
    print(event.kind)

for agent in paginate(client.agents.list, limit=50):  # every page
    print(agent.id)
```

Without arguments, the client takes `KINDGI_API_URL` and `KINDGI_API_TOKEN`
from the environment. In development, when they're unset, it uses the running
`kindgi dev` (the nearest `.kindgirc.json` at or above the working directory)
and warns once (`KindgiConfigWarning`) to put them in your env file (`.env` /
`.env.local`). Production (`KINDGI_ENV` or `NODE_ENV` set to `production`)
never reads `.kindgirc.json`. It's the same lookup as the TypeScript SDK's
`createClient()`.

- A request body is a model from `kindgi.client.models`, a mapping, or its
  fields as keywords (snake_case or the wire's camelCase); answers are models.
- A model has its API schema's name (`Flow`, `Tool`). An object inside a
  schema is named after the field that holds it, and after the schema too when
  that name is another schema's (`CorrectedRunContextFlow`). A union's members
  are named by the value that tells them apart (`ScopeTenant`, `ScopeOrg`,
  `ScopeProject`).
- **Newer runtimes are expected.** An enum in an answer is typed
  `Literal[...] | str`: a value this client doesn't know comes back as its
  text. A union in an answer has a member for a kind it doesn't know,
  `<Union>Unrecognized` (`ScopeUnrecognized`), with every field kept. A
  request body is checked against the values this client knows before it's
  sent, so a value read from a newer runtime can't be sent back.
- Path parameters are positional; query and header parameters keyword-only.
- An operation that takes an `Idempotency-Key` gets one when you pass none.
  Retries (`max_retries=2`, honouring `Retry-After`):
  - **A GET** is retried after a connection error or timeout, or a 429, 502,
    503 or 504.
  - **Any other call** is retried only when nothing can have run: a failure to
    connect (or a connect or pool timeout), or a 429 or 503.
  - **Once a call was sent,** a timeout, a dropped connection or a proxy's 502
    or 504 is raised at once. The call may still be running on the server, and
    sending it again could run it twice.
  - **A waited `runs.start` that times out** says so, and that the run may
    still be going. Start a run that can take longer than `timeout` (60 s by
    default) with `options={"wait": False}`, and follow it.
- Errors are typed: `NotFoundError`, `ConflictError`, `InvalidRequestError`,
  `GuardrailViolationError`, `AuthError`, `RateLimitedError`, `ServerError`,
  `NetworkError` — all `KindgiApiError`, with `.status`, `.server_code`,
  `.details` and `.request_id`.
- `AsyncKindgi` is the same with asyncio (`apaginate` for its pages).

A pack's tools can use it to call back into Kindgi (memory, events, other runs).

## Receiving webhooks

Kindgi signs the webhooks it sends (`run.finished`, `webhook.test`; see
[webhooks](https://docs.kindgi.com/v0.1/guides/webhooks/verify-a-webhook/)) in the
[Standard Webhooks](https://www.standardwebhooks.com) format. Verify the
**raw** body, before parsing it, with the secret the endpoint's `secretRef`
names:

```python
import os

from fastapi import FastAPI, Request, Response
from kindgi import webhooks

app = FastAPI()


@app.post("/hooks/kindgi")
async def kindgi_hook(request: Request) -> Response:
    body = await request.body()  # the bytes as sent, never re-serialized JSON
    try:
        delivery = webhooks.verify(os.environ["KINDGI_WEBHOOK_SECRET"], request.headers, body)
    except webhooks.WebhookVerificationError:
        return Response(status_code=401)
    if already_handled(delivery.id):  # delivery is at least once
        return Response(status_code=204)
    event = webhooks.parse_event(body)  # RunFinishedEvent | WebhookTestEvent
    if event.type == "run.finished":
        print(event.data.run.flow_id, event.data.run.status)
    return Response(status_code=204)
```

- `verify` takes the headers as any mapping (Starlette, Django, aiohttp, a
  `dict`), Werkzeug's `Headers` or `http.server`'s, names in any case. It rejects a timestamp
  more than 5 minutes from your clock (`tolerance_seconds=`) and compares in
  constant time; `WebhookVerificationError.reason` says which check failed.
- During a secret rotation requests carry a signature per secret, so the old
  and the new secret both verify.
- `sign` / `signed_headers` make signed requests, for testing a receiver;
  `generate_secret` makes a strong secret.

## Testing your code

A `Tool` and a `Guardrail` stay callable:

```python
from kindgi import ToolContext


def test_record_expense():
    out = record_expense(Expense(vendor="Acme", amountCents=100), ToolContext.for_test())
    assert out.expense_id
```

## Developing this package

With uv 0.12 (`[tool.uv] required-version` in `pyproject.toml`; uv refuses
to run here otherwise, and CI installs its version from that line):

```sh
uv sync                     # .venv with the dev tools
uv run pytest
uv run ruff check src tests && uv run ruff format --check src tests
uv run pyright              # strict
```

The vendored schemas in `src/kindgi/_specs` are copies of `@kindgi/specs`;
`tests/test_specs_drift.py` fails when they drift. The client's
`_models.py` and `_resources.py` are generated —
`uv run python scripts/gen_client.py` after `openapi.json` changes;
`tests/test_client.py` fails while they are out of step. `scripts/model_names.json`
holds where each model sits, and generation fails when a model's name would
move to another schema or vanish: such a change is an edit to that file, and
an entry in `CHANGELOG.md`. A name that goes keeps its line with no places
(`[]`): it stays dropped, and generation fails if a model would take it again.
A union's members are named by their discriminator (`ScopeTenant`,
`MCPEndpointConfigStdio`); in a union without one, a member sits at its
properties, so a member that gains a property reads as moved too: check it,
then edit its line.
`@kindgi/pack-conformance` runs its suite against this package's `.venv`.

The version is the npm packages' (`scripts/sync-python-version.mjs`
moves it); changes go in [`CHANGELOG.md`](./CHANGELOG.md).

## License

Apache-2.0 — see [LICENSE](./LICENSE).
