Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
263 changes: 263 additions & 0 deletions docs/rfc/0001-autonomous-agent-self-service-provisioning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
# RFC 0001: Autonomous Agent Self-Service Project Provisioning

## Status

Draft / Proposed — request for comments. Not accepted, nothing implemented.

## Date

2026-05-27

## Context

The goal under discussion: **an external AI agent downloads `kbagent`, creates
its own Keboola project, and — over time — pays for it, with no human in the
operational loop.** "External" means the agent is *not* operating inside a
Keboola organization that already exists; it wants to become a customer in its
own right.

This is the hardest of three scenarios (the other two — Keboola provisioning
projects inside its own org, and a Keboola customer's agent provisioning inside
the customer's already-billed org — are strictly easier and are *not* the
subject of this RFC).

### What exists today (the `kbagent` side)

- `ManageClient` ([manage_client.py](../../src/keboola_agent_cli/manage_client.py))
wraps `get_project`, `list_organization_projects`, `create_project_token`,
invitations and members. **It has no `create_project` wrapper** — so `kbagent`
cannot create a project today, even though the Manage API endpoint itself
exists (see "Verified Manage API surface" below). Account/organization
creation has no API at all.
- `OrgService.setup_organization` ([org_service.py](../../src/keboola_agent_cli/services/org_service.py))
registers *already-existing* projects: it mints a Storage token via the Manage
API and writes the project into `config.json`. The provisioning chain is:

```
create_account/org (NO API) → create_project (API exists, wrapper MISSING) → create_project_token (EXISTS) → project add (EXISTS)
```

- The Manage token is **default-deny** (CLAUDE.md convention #12): never
persisted, never a CLI argument, env var ignored without
`--allow-env-manage-token`. An agent must never hold a privileged credential.
- [ADR 0002](../adr/0002-sec-09-config-privilege-separation.md) establishes the
binding principle this RFC inherits: *a guard can only constrain a principal
that lives in a different trust domain.* Quotas and spend caps are only real
if enforced **outside** the agent's trust domain.

### What exists today (the Keboola platform side)

Verified against public sources (2026):

- Keboola **has** a public self-service signup and an **always-free tier with no
credit card required**; billing is freemium / usage-based (free runtime
minutes, then per-minute overage).
- There is **no public API for account or organization creation** — signup is a
**web UI flow only**. This is the single most important constraint in this RFC.

### Verified Manage API surface (2026)

Confirmed from the official `keboola/kbc-manage-api-php-client` (`src/Client.php`):

- `createProject(int $organizationId, array $params)`**`POST /manage/organizations/{orgId}/projects`**. The body is a free-form object (the PHP client passes `$params` straight through); from the UI we know **`name`** and a **project template/type** are the meaningful inputs, the rest optional (backend, data-retention, …). Requires an **org-admin Manage token** and an **already-existing `organizationId`**.
- `createProjectStorageToken(int $projectId, array $params)``POST /manage/projects/{id}/tokens` — exactly what our `create_project_token` already wraps.
- `giveProjectCredits(int $projectId, array $params)``POST /manage/projects/{id}/credits`**a billing primitive already exists** (relevant to layer 4 / Phase 2).
- A marketplace-token path (`resolveMarketplaceToken` → project creation from an AWS/marketplace purchase token) exists in Keboola billing code — an existing precedent for "purchase → project" automation.

Net: **layer-1 project creation is a confirmed, thin wrapper**; the genuine gap for the *external* scenario is the **account/organization creation + free-tier-via-API + mandate** front door, which is *not* part of this Manage API surface.

## The problem is four independent layers, not one

| Layer | Question | External-scenario difficulty |
|---|---|---|
| 1. Provisioning | Technically create the project | 🟢 small once an org exists; 🔴 no API to create the *account/org* |
| 2. Root of trust | Who authorizes the agent to provision | 🟡 solvable via a sponsor-issued scoped credential |
| 3. Identity & legal | Who owns the account and accepts ToS | 🔴 hard ceiling — the agent is not a legal person |
| 4. Payment | Who pays, and how | 🔴 solvable only by delegated budget; never the agent itself |

The reason "agent creates and pays for a project with no humans" *feels*
impossible is that these four are usually conflated. Separated, three of them
have concrete answers and one is a genuine product/legal decision.

## Core realization: "no humans" means moving consent to a sponsor mandate

No 2026 payment framework (AP2, Stripe/OpenAI ACP, x402) grants an AI agent
legal personhood. In every model the **principal is a human or organization**;
the agent is an executor acting under a delegation. "No human in the loop" is
therefore achievable only in one precise sense:

> A **sponsor** (a legal person or organization) signs a **mandate once**,
> pre-authorizing the agent to onboard, provision, and spend within stated
> bounds. The agent then operates autonomously *inside* that mandate with no
> per-action human approval.
This is exactly Google AP2's **Intent Mandate** (the *Human-Not-Present* case):
the sponsor pre-approves conditions — budget ceiling, project count, allowed
operations, ToS acceptance on the agent's behalf — and the agent acts later
without the human present. It is *not* an agent acting with no principal at all;
that is neither legal nor desirable.

`★ The two facts that make this tractable`
1. Keboola's **always-free tier needs no card**, so **onboarding and payment are
separable**. An agent can be fully onboarded onto a free project *before*
payment is ever solved. "Pay over time" is a strictly later phase.
2. The decisive blocker is **not** in this repo. It is the **missing
Keboola-side signup/onboarding API**. `kbagent` can build the entire client
half, but the platform must expose an agent-onboarding endpoint first.

## Proposed architecture

### A. Onboarding (layers 1 + 2) — *requires a new Keboola platform API*

A new **Agent Onboarding API** on the Keboola platform (NOT in this repo) that
exchanges a signed sponsor mandate for a narrowly-scoped onboarding result:

```
agent ──(signed Intent Mandate)──▶ POST /agent-onboarding (Keboola platform, NEW)
│ verify mandate signature + sponsor identity
│ enforce per-mandate project quota
free-tier project + scoped Storage token (no Manage token)
```

The token returned is a **project-scoped Storage token**, never a Manage token —
the agent gets exactly enough to work in its one project and nothing org-wide.
On the client side, `kbagent` adds a single thin command:

```
kbagent onboard --mandate <signed-mandate-file>
→ calls /agent-onboarding, then reuses the existing `project add` path to
register the returned project + token in config.json
```

This keeps the [ADR 0001](../adr/0001-agent-office-product-boundary.md) boundary
intact: `kbagent` stays the local Keboola capability provider; onboarding is one
more governed capability, and the privileged half lives on the platform.

### B. Identity & legal (layer 3) — hard ceiling, handled by the mandate

The agent cannot be the legal owner. The mandate names the **sponsor** as the
account principal and carries the sponsor's ToS acceptance. The provisioned
project is *owned by the sponsor*; the agent holds delegated access only. This
does not remove the human — it **relocates** the human to a one-time signing
event outside the operational loop.

### C. Payment (layer 4) — phased, always a delegated budget

| Phase | Mechanism | Who pays / is liable | Maturity |
|---|---|---|---|
| 1 | **Free tier, no payment** | nobody — within free limits | 🟢 ships first |
| 2 | **Prepaid balance + auto-topup** bound to the sponsor's instrument | sponsor signs a recurring top-up mandate | 🟢 mature pattern |
| 3 | **x402 / AP2 rail** for usage-metered spend | sponsor's wallet/instrument under policy | 🟡 emerging, optional |

In the Keboola model, billing already aggregates to the Organization/Maintainer,
so "pay for the project" reduces to "the sponsor's billing account funds a
prepaid balance the agent draws down." The agent never holds the payment
instrument. Notably the Manage API already exposes `giveProjectCredits`
(`POST /manage/projects/{id}/credits`) and a marketplace-purchase → project path,
so Phase 2 builds on **existing** billing primitives rather than net-new surface.

### D. Trust model (inherited from ADR 0002)

Sponsor and agent are **two trust domains**. Every limit that matters is enforced
on the platform side, never in a CLI flag the agent can override.

| Principal | Holds | May do | Enforced by |
|---|---|---|---|
| Sponsor (legal person/org) | payment instrument, ToS acceptance, mandate signing key | set budget, project quota, revoke mandate | — (root of trust) |
| AI agent | a signed mandate + a project-scoped Storage token | onboard + operate inside one project, up to quota/spend cap | **Keboola platform** (quota, spend cap, mandate expiry) — *not* `kbagent` |

The CLI-side `--deny-writes` / `--deny-destructive` flags remain *guard rails
against agent mistakes*, exactly as ADR 0002 frames them — they are not the
enforcement boundary for an adversarial or runaway agent. The enforcement
boundary is the platform's per-mandate quota and spend cap.

## What would need to be built

**Keboola platform (outside this repo — product dependency):**
- Agent Onboarding API: mandate verification, sponsor-identity binding,
per-mandate project quota, free-tier project creation, scoped-token issuance.
- Server-side spend cap + project quota enforcement tied to the mandate.
- Anti-abuse: rate limiting and sponsor-bound quotas (see Risks).

**`kbagent` (this repo):**
- `ManageClient.create_project()` + `OrgService.provision_project()` — the
missing layer-1 wrapper. Useful *immediately* for the easier internal scenarios
and as the test harness for everything here.
- `kbagent onboard --mandate ...` — client for the new platform API.
- Mandate handling (parse/verify a sponsor-signed Intent Mandate VC).

## Phasing

- **Phase 0 — `project create` wrapper.** Add `create_project` to `ManageClient`
(`POST /manage/organizations/{id}/projects`, endpoint now confirmed) and a
`kbagent org provision` command. Unlocks the internal/customer scenarios today
and is the test substrate for the rest. *Fully inside this repo; no platform
work.*
- **Phase 1 — free-tier mandate onboarding.** Requires the platform Agent
Onboarding API + `kbagent onboard`. No payment. Delivers "agent onboards itself
with no human in the loop" against the free tier.
- **Phase 2 — prepaid + auto-topup.** Sponsor-funded balance; agent spends within
cap. Delivers "pays over time."
- **Phase 3 — x402 / AP2 rail.** Optional usage-metered autonomous payment.

## Open questions

1. **Will Keboola expose a public agent-onboarding front door?** Project creation
*within an existing org* is a confirmed Manage API endpoint, but it needs an
org-admin token and a pre-existing organization. **Account/organization
creation and free-tier provisioning have no public API** (web-UI only), and
there is no mandate-exchange endpoint. That front door is the gating product
decision — without it the external scenario cannot exist in robust form.
2. **Own organization per agent, or a shared "agent org"?** Affects billing
aggregation, isolation, and anti-abuse.
3. **Mandate format:** adopt AP2 Verifiable Credentials, or a Keboola-specific
signed token? AP2 buys interoperability with the wider agent-payments
ecosystem at the cost of early-stage maturity.
4. **Anti-abuse:** free tier + no card + programmatic onboarding = a project-farm
incentive. What binds a mandate to a real, rate-limited sponsor?
5. **KYC for paid accounts:** at what spend threshold does a paid agent account
require sponsor verification?

## Risks / Honest limits

- **The blocker is not in this repo.** Without a Keboola-side signup/onboarding
API, the external scenario is blocked at a layer `kbagent` cannot reach. This
RFC can deliver Phase 0 alone; Phases 1–3 are platform-gated.
- **The agent is never a legal person.** ToS acceptance, ownership, and liability
always rest with the sponsor. "No humans" is "no humans *in the loop*," not "no
humans *at all*."
- **Anti-abuse is first-class, not an afterthought.** A programmatic free-tier
onboarding path is a spam/fraud magnet; it must ship *with* rate limiting and
sponsor-bound quotas, not after.
- **Caps must be server-side.** Per ADR 0002, any quota or spend cap expressed as
a CLI flag is friction, not enforcement — the agent shares the CLI's trust
domain and can override it. Enforcement lives on the platform.

## Alternatives considered

### Automate the web signup flow (headless browser)

Drive the public signup UI with Playwright/Chrome. **Rejected.** Brittle against
UI changes, fights anti-bot defenses, and almost certainly violates ToS. It also
provides no mandate/audit trail and no clean payment delegation. This is the
first idea everyone has and it is a dead end.

### Crypto-only (x402 as the sole rail)

Give the agent a stablecoin wallet and pay per call. **Rejected as the sole
rail.** Narrow ecosystem, ignores the free-tier on-ramp, and solves none of the
identity/ToS problem. Kept as an *optional* Phase 3 rail.

### Internal-only provisioning

Only ever let agents provision inside an existing, already-billed org.
**Out of scope** — that is the easier scenario this RFC explicitly excludes,
though Phase 0 delivers exactly it as a byproduct.

## Related documents

- [ADR 0001 — Agent Office Product Boundary](../adr/0001-agent-office-product-boundary.md)
- [ADR 0002 — Config Privilege Separation](../adr/0002-sec-09-config-privilege-separation.md)
- CLAUDE.md convention #12 (Manage token default-deny)
- `OrgService.setup_organization` — the existing register-existing-projects path
38 changes: 38 additions & 0 deletions src/keboola_agent_cli/manage_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,44 @@ def list_organization_projects(self, org_id: int) -> list[dict[str, Any]]:
response = self._do_request("GET", f"/manage/organizations/{org_id}/projects")
return response.json()

def create_project(
self,
organization_id: int,
name: str,
extra_params: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Create a new project inside an organization.

Wraps ``POST /manage/organizations/{orgId}/projects``. The Manage API
accepts a free-form body (the official ``kbc-manage-api-php-client``
passes ``$params`` straight through). ``name`` is surfaced explicitly
because it is the one field the UI always requires; every other
documented field (project template/type, backend, data retention, ...)
is passed through verbatim via ``extra_params`` so this wrapper does not
guess the Manage API schema.

Requires an organization-admin Manage token.

Args:
organization_id: The organization to create the project in.
name: Human-readable project name (the one always-required field).
extra_params: Additional request-body fields passed through verbatim
(e.g. project template/type, backend, data-retention settings).

Returns:
The created project dict (includes the new project ``id``).

Raises:
KeboolaApiError: On API errors.
"""
payload: dict[str, Any] = {"name": name}
if extra_params:
payload.update(extra_params)
Comment on lines +140 to +142
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 extra_params can silently override the explicit name argument

In create_project(), the payload is built as {"name": name} and then extra_params is merged via payload.update(extra_params) at manage_client.py:142. If extra_params contains a "name" key, it silently overwrites the value passed as the explicit name argument. The caller sees name as a required, first-class parameter and would reasonably expect it to always be the project name used — but extra_params takes precedence due to the merge order. The fix is to reverse the merge order so name always wins, e.g. payload = {**(extra_params or {}), "name": name}.

Suggested change
payload: dict[str, Any] = {"name": name}
if extra_params:
payload.update(extra_params)
payload: dict[str, Any] = {**(extra_params or {}), "name": name}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

response = self._do_request(
"POST", f"/manage/organizations/{organization_id}/projects", json=payload
)
return response.json()

def create_project_token(
self,
project_id: int,
Expand Down
77 changes: 77 additions & 0 deletions tests/test_manage_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,83 @@ def test_empty_org(self, httpx_mock) -> None:
client.close()


class TestCreateProject:
"""Tests for create_project()."""

def test_success(self, httpx_mock) -> None:
"""Creates a project and returns the response dict with the new id."""
httpx_mock.add_response(
url=f"{STACK_URL}/manage/organizations/42/projects",
json={"id": 12345, "name": "Agent Sandbox"},
status_code=201,
)

client = ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN)
result = client.create_project(organization_id=42, name="Agent Sandbox")

assert result["id"] == 12345
assert result["name"] == "Agent Sandbox"
client.close()

def test_name_is_sole_payload_key_without_extras(self, httpx_mock) -> None:
"""With no extra_params, name is the only body field sent."""
httpx_mock.add_response(
url=f"{STACK_URL}/manage/organizations/42/projects",
json={"id": 1, "name": "Agent Sandbox"},
status_code=201,
)

client = ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN)
client.create_project(organization_id=42, name="Agent Sandbox")

request = httpx_mock.get_request()
import json

body = json.loads(request.content)
assert body == {"name": "Agent Sandbox"}
client.close()

def test_extra_params_merged_into_payload(self, httpx_mock) -> None:
"""extra_params are passed through verbatim alongside name."""
httpx_mock.add_response(
url=f"{STACK_URL}/manage/organizations/42/projects",
json={"id": 1, "name": "PoC"},
status_code=201,
)

client = ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN)
client.create_project(
organization_id=42,
name="PoC",
extra_params={"type": "poc6months", "defaultBackend": "snowflake"},
)

request = httpx_mock.get_request()
import json

body = json.loads(request.content)
assert body["name"] == "PoC"
assert body["type"] == "poc6months"
assert body["defaultBackend"] == "snowflake"
client.close()

def test_403_access_denied(self, httpx_mock) -> None:
"""Raises KeboolaApiError with ACCESS_DENIED on 403 (non-admin token)."""
httpx_mock.add_response(
url=f"{STACK_URL}/manage/organizations/42/projects",
json={"error": "You don't have access to this organization"},
status_code=403,
)

client = ManageClient(stack_url=STACK_URL, manage_token=MANAGE_TOKEN)
with pytest.raises(KeboolaApiError) as exc_info:
client.create_project(organization_id=42, name="Nope")

assert exc_info.value.error_code == "ACCESS_DENIED"
assert exc_info.value.status_code == 403
client.close()


class TestCreateProjectToken:
"""Tests for create_project_token()."""

Expand Down
Loading