English · Русский
Developer guide
This guide shows one representative example per domain, enough to get an integration running. For the complete list of every operation, method, path, tier and scope, use the operation reference or the OpenAPI document.
See also: index.md (index) · errors.md (401/403/404/409/429/501/503 handling) · security-model.md (localhost, key model, audit) · versioning-and-deprecation.md (semver policy) · openapi/index.html (full OpenAPI reference, rendered with Redoc).
Quickstart (5 minutes, from nothing)
- Issue a key — see below. Copy the raw key; it is shown exactly once.
- Confirm the key works:
curl -H "Authorization: Bearer <api-key>" http://localhost:5299/api/v1/auth/whoamiExpect
200with your key’sscopes/maxTierechoed back. - Check what you can actually do on this host:
curl -H "Authorization: Bearer <api-key>" http://localhost:5299/api/v1/capabilitiesWalk the
operationsarray — every one your key can call right now hasisAvailable: true. - Make your first safe (
T0) call — no side effects, e.g.:curl -H "Authorization: Bearer <api-key>" http://localhost:5299/api/v1/projects/current - Handle the predictable failure modes before writing anything that mutates state — see errors.md.
5299 above is ProjectMaker’s default port; a ZennoPoster Core host binds its own port the same
way (localhost only either way — see security-model.md).
Issuing a key
Keys are managed from the API Keys page in ProjectMaker/ZennoPoster Settings, available to whoever runs the product on that machine — there is no separate remote signup flow. The “Add key” dialog collects:
- Label — free text, for your own bookkeeping (shown in
GET /auth/keys, never the raw key). - Max tier —
T0–T3(see the table below); defaults toT0(least privilege). - Scopes — a checklist grouped by domain (
project:*,task:*,instance:*,code:*,admin); only*:readscopes are pre-checked, everything else (including anything mutating, running, orcode:author) is opt-in. - Expiry — optional; on the wire (
POST /auth/keys) it is an absoluteexpiresAtISO-8601 timestamp,null/omitted for a non-expiring key. A past date is rejected with400.
On confirm, the raw key is displayed once (GitHub Personal-Access-Token style) — copy it
immediately; it cannot be retrieved again (see security-model.md for why).
From then on, send it as Authorization: Bearer <raw-key> on every request except GET /api/v1/ping.
Scopes and tiers
| Tier | Meaning | Gate |
|---|---|---|
T0 |
Read / inspect, no side effects. | ApiKey only. |
T1 |
Edits the project/model, reversible, no execution. | ApiKey + scope. |
T2 |
Execution with the application’s own privileges (run a task, complete a session). | ApiKey + scope + audit. |
T3 |
Reaches the OS level — filesystem/network/OwnCode compile+run, or key/audit administration. RCE-class. | ApiKey + scope + audit, and a person’s approval when the owner turned that on (security-model.md). |
Scopes are namespaced by domain: project:read/edit/run/record, task:read/edit/control,
instance:read/control/interact, code:read/author, and the cross-cutting admin. The content
scopes (io:filesystem, io:network, io:database, system:exec, code:author) are demanded at
execution time: starting a task or project whose actions touch the filesystem, network,
database or OS requires the matching io:*/system:exec scope on top of the operation’s own
scope, and a code-based or OwnCode-containing project requires code:author. The 403 body enumerates exactly
what is missing (missingScopes, dangerousCategories, unknownCategories).
Every operation carries exactly one tier and at most one required scope (null for
ping/whoami/capabilities, which only need a valid key, if any at all — ping needs none).
Request examples by domain
All paths below are relative to /api/v1. Every response uses the shared envelope (resultCode
for the ProjectMaker domain, or a domain-specific body — see the OpenAPI document for the exact
per-operation schema; this guide shows one representative example per domain to get you oriented,
not the full set).
ProjectMaker (project:*) — host :5299
GET /projects/current (T0, project:read) — info about the open project
POST /projects/current/actions (T1, project:edit) — add an action to the project
POST /projects/current/actions/{groupId}/{actionId}/execute
(T3, project:run) — execute a single action (RCE class)
Adding an action whose type is OwnCode additionally requires
code:author (T3) on top of project:edit: authoring new executable code and running an existing
project are gated separately, so a low-privilege key can run a trusted project without being able
to inject and execute new code.
The project’s shared code block is in the same class. GET /projects/current/shared-code reads the
using directives and common code at T0 with code:read, while PUT on the same path needs
code:author at T3, because that code is compiled into the project and runs with it. The block’s
GAC reference list is separate: GET /projects/current/gac-references is T0 project:read and the
PUT that replaces it is T1 project:edit, since a reference on its own executes nothing.
Step debugging (breakpoint → run → pause → step)
The four debug_* operations drive the ProjectMaker debugger the way the toolbar buttons do. Step and
run execute cubes, so they are T3 project:run and pass the same per-content gate as
project_start; pause is T2 project:run; status is T0 project:read, so a read-only key can watch
a run it cannot drive. Step, run and pause return before the cube runs — the outcome is only ever
read from debug_status, so every drive call is followed by a poll:
# 1. put a breakpoint on the cube to stop at
curl -X PATCH -H "Authorization: Bearer <api-key>" -H "Content-Type: application/json" \
-d '{"breakpoint":true}' http://localhost:5299/api/v1/projects/current/actions/{groupId}/{actionId}/properties
# 2. start the run, then poll until the state is no longer Running
curl -X POST -H "Authorization: Bearer <api-key>" -d '{}' http://localhost:5299/api/v1/projects/current/start
curl -H "Authorization: Bearer <api-key>" http://localhost:5299/api/v1/projects/current/debug/status
# → {"state":"Paused","actionId":"<the breakpoint cube>","groupId":"...","breakpointHit":true,
# "actionFailed":false,"executionError":null}
# 3. look around, then continue one cube ("Next") or up to the next breakpoint ("To breakpoint")
curl -H "Authorization: Bearer <api-key>" http://localhost:5299/api/v1/projects/current/variables
curl -X POST -H "Authorization: Bearer <api-key>" -d '{}' http://localhost:5299/api/v1/projects/current/debug/step
curl -X POST -H "Authorization: Bearer <api-key>" -d '{}' http://localhost:5299/api/v1/projects/current/debug/run
# poll debug/status again after each: Paused = stopped on the next cube, Idle = the project reached its end
# 4. interrupt a long cube without losing the run
curl -X POST -H "Authorization: Bearer <api-key>" -d '{}' http://localhost:5299/api/v1/projects/current/debug/pause
# → status reads Stopping until the cube finishes, then Paused; debug/step or debug/run continue from there
state is one of Idle (nothing to resume — never started, or the project reached its end), Running,
Paused (between cubes: breakpoint, step, pause or a failed cube) and Stopping (pause or stop
requested, the current cube is still finishing). On a failed cube the run stays paused on that cube
with actionFailed: true and its message in executionError. A second step or run while a cube is
still executing is refused with 409 failed_precondition; debug/pause and /projects/current/stop
on a paused run are successful no-ops — neither executes a cube. Right after project_start there is
a short window in which the status already reads Running but the debugger has not started yet: step,
run, pause and stop are refused with the same 409 failed_precondition there — poll debug/status
and retry once it is Running or Paused. debug/step and debug/run accept an
optional {"fromCursor": true|false} for the case where the editor cursor was moved away from the cube
the run paused on: true continues from the cursor, false from the paused cube, omitted follows the
user’s saved ProjectMaker choice. A ready-made smoke run of this loop is test/Smoke/publicapi-debug-stepping.ps1
in the product repository.
ZennoPoster tasks (task:*) — host = ZennoPoster Core
GET /tasks (T0, task:read) — list tasks
POST /tasks/{id}/start (T3, task:control) — start a task (content-classified, see above)
PUT /tasks/{id}/config (T1, task:edit) — update a task's user settings
POST /tasks (add a task) is T3/task:edit — it either references a project file already on
disk (application/json body { "path": "C:\\bots\\my.zp" }) or uploads the project bytes
(application/octet-stream, ?name=my.zp). Only .zp, .zpproj and .vsproj are accepted.
The ZennoDroid runner takes its own .droid projects in place of .zp.
Treat it as risky regardless of variant: an imported project can itself contain OwnCode.
Which start-shaped calls are gated. Everything that can launch project code runs the same
content-classification gate as /start: adding/setting a non-zero tries value on a READY task
(Newbie/Complete/NotComplete — the ZP7 semantics shared with the UI “+N” buttons),
PUT /settings/execution whose resulting tries counter is non-zero on a ready task, and
PUT /settings/scheduler with isActive: true (an active schedule is a deferred start). On a
stopped/running/scheduled task the same calls are plain T1 edits.
Task settings
GET /tasks/{id} returns settingsType, which tells you where the task’s user-facing settings
live: InputSettings — the project declares a raw input-settings block; BotUI — the
project uses a BotUI form; None — the project has no user settings. Either way,
GET/PUT /tasks/{id}/config is THE settings pair: it serves the unified view covering both block
kinds. The settingsType field can be empty until the project has been scanned at least once
(first start or UI open).
Settings are name+value pairs bound to project variables:
// GET /tasks/{id}/config
{ "settings": [ { "name": "Login", "variable": "login", "value": "user@example.com" } ] }
// PUT /tasks/{id}/config — send only what you change; matching is by name (+variable to
// disambiguate duplicates). The response tells you what actually matched:
{ "applied": ["Login"], "skipped": ["Loginn"] }
A name in skipped matched nothing on the task (typo, or the setting belongs to the other block
kind) — the import semantics skip it rather than fail the batch.
Execution settings (merge semantics)
PUT /tasks/{id}/settings/execution merges: every field is optional and an omitted field keeps its
current value, so { "priority": 50 } changes the priority and nothing else. GET returns the
full current set. Validation is strict (400 otherwise): limitOfThreads >= 1;
numberOfTries >= 0; priority ∈ 10 (low), 50 (medium), 100 (high), 100000 (critical);
maxNumOfSuccessStop/maxNumOfFailStop = -1 (unlimited) or >= 1; timeout = -1 (none) or
minutes >= 1; proxy ∈ DoNotUseProxy, IfPossible, UseProxyWithoutRemove, UseProxy.
The system-managed maxAllowOfThreads ceiling is not writable — read it in GET /tasks/{id}/state.
Threads and tries
/tasks/{id}/threads/max (GET/PUT) is the user-set thread limit (limitOfThreads);
/tasks/{id}/tries (GET/PUT) and /tries/add (POST) manage the queued-tries counter. The number
of currently running threads is a different value — countOfThreads in GET /tasks/{id}/state.
PUT responses return the value actually applied after the commit, not an echo of the request.
Runtime state and failure points
GET /tasks/{id}/state returns counters plus failurePoints: a snapshot of the last failure per
worker thread, NOT an event queue. Reading is non-destructive — repeated GETs return the same
entries; an entry is overwritten in place when its thread fails again (keyed by taskId+threadId),
expires after 2 hours, is capped at 64 entries per task, and is fully cleared only when the task is
deleted. Deduplicate on the client by (threadId, timestamp) or uowId. Note: the variables
snapshot inside a failure point can contain sensitive values and is readable with task:read (T0) —
scope your keys accordingly.
GET /tasks/{id}/logs (T1, task:read) returns the execution log leading up to a failure: the
records the project wrote and the actions the engine went through, merged in time order, oldest
first. Query parameters: logLevel is a severity mask (Info=1, Warning=2, Error=4, default 7; 4
returns the failing steps together with the error records), skip and take page the result
(default 100 entries), uowId limits it to one attempt. The log is held in memory: the last 8
attempts of each task, up to 2000 entries per attempt, for 2 hours. An empty result means nothing is
kept for that task. Entries carry whatever the project logged, including sensitive values.
Scheduler settings
GET/PUT /tasks/{id}/settings/scheduler. Formats: lists are comma-separated; daysOfWeek uses
English day names, capitalized (Monday … Sunday — other casings are accepted and normalized,
unknown names are a 400); intervals entries are "HH:mm-HH:mm" (an end of "00:00" means
24:00 — end of day; a single time like "09:30" is a point interval); attemptsRange,
repeatCountDayRange and repeatCountTotalRange are "n" or "a-b"; dates
(startDate/endDate) are invariant-culture strings in the MACHINE’S LOCAL TIME, with no time
zone — the one exception to the API-wide absolute-time convention, because the scheduler runs on
the machine’s clock. isActive: true requires a real executePeriod
(400 on NULL) and is gated like /start (see above). Four typical bodies:
// 1. One-time run at a specific date/time
{ "isActive": true, "executePeriod": "OneTime", "startDateType": "OnDate",
"startDate": "08/30/2026 09:00:00", "attemptsRange": "1", "isClearSuccess": false }
// 2. Every day, between 09:00 and 18:00, repeating regularly through the day
{ "isActive": true, "executePeriod": "EveryDay", "startDateType": "Immediately",
"attemptsRange": "1", "isClearSuccess": true,
"intervals": ["09:00-18:00"], "stopExecutionOutsideOfIntervals": true,
"repeatType": "Regularly", "repeatCountDayRange": "10", "endDateType": "Infinity" }
// 3. On selected weekdays
{ "isActive": true, "executePeriod": "EveryWeek", "daysOfWeek": ["Monday", "Wednesday", "Friday"],
"startDateType": "Immediately", "attemptsRange": "1-3", "isClearSuccess": false,
"repeatType": "Continued", "endDateType": "Infinity" }
// 4. When a trigger file appears (the file is deleted after firing)
{ "isActive": true, "executePeriod": "OnDemand", "fileName": "C:\\triggers\\run.txt",
"isNeedDeleteFile": true, "attemptsRange": "1", "isClearSuccess": false }
Interaction sessions (task:*) — host = ZennoPoster Core
A session is a live WaitForUserAction window on a running task/instance.
GET /sessions (T0, task:read) — list open windows
POST /sessions/{id}/complete (T2, task:control) — resolve the window, resume the thread
{ "summary": "approved", "metadata": {} } is a typical complete body. If the window already
closed (timeout, or another caller resolved it), you get 409 no_active_interaction — see
errors.md. There is no cancel/fail-branch endpoint in this version.
A session’s started is the UTC ISO-8601 moment the WaitForUserAction window opened; together
with deadline (present when the action has a timeout) it gives you the remaining time.
Watching for windows: GET /sessions/events?timeoutSeconds=N (1–60, default 25) is a bounded
long-poll — it blocks until at least one open/close event arrives (bursts are batched) or the
window elapses, then returns { "events": [...], "timedOut": bool }. It is a live watch, not a
durable log: there is no backlog, so events between two polls are missed — re-poll in a loop, and
use GET /sessions for a point-in-time snapshot. Concurrent polls per host are capped (default
32; excess gets 429 rate_limited).
Instance (browser) — instance:*
Shared contract, two hosts: ProjectMaker exposes its single debug instance; a ZennoPoster runner
exposes one instance per running task/port. Driving tabs and the DOM is ordinary automation: an
instance:interact call needs no open session window — it is bounded by the instance:interact
scope and the operation’s tier alone. (A WaitForUserAction window is simply the common case where
a task is waiting for you to drive it; it is not a precondition.)
GET /instances (T0, instance:read)
POST /instances/{id}/tabs/{tabId}/navigate (T1, instance:interact) — { "url": "...", "timeout": 5 }
POST /instances/{id}/tabs/{tabId}/elements/event (T1, instance:interact) — { "xpath": "...", "eventName": "click" }
GET /instances/{id}/tabs/{tabId}/screenshot (T0, instance:read) — ?fullPage=true&maxWidth=1280
.../screenshot returns a PNG, base64-encoded: the visible viewport by default, or the page from
the top with fullPage=true. The image is fitted into 1280 px on the long side unless maxWidth or
maxHeight is given (2000 px at most per side); scale and isTruncated in the response say
whether it was shrunk or cut.
Release ≠ close. DELETE /instances/{id} gracefully releases an API-started browser back to
the pool, and the pool decides release-vs-restart itself — the window may stay alive for
reuse; that is by design, not a leak. Ports owned by a running task’s worker thread are protected
with 409 instance_busy. POST /instances/{id}/show answers 409 instance_view_protected when
the browser’s view is protected (view protection enabled, without an open WaitForUserAction
window) — a 200 always means the window was actually revealed.
Cross-cutting (admin, or no scope)
GET /auth/whoami (T0, no scope) — your key's own scopes/tier
GET /capabilities (T0, no scope) — the manifest below
POST /auth/keys (T3, admin) — issue a key (same operation the UI calls)
GET /audit (T0, admin) — the call log
GET /plugins/trust (T0, admin) — plugin files the owner pinned, by SHA-256
POST /plugins/trust (T2, admin) — pin an installed plugin file: {"path": "Local\\my-plugin.zpg"}
DELETE /plugins/trust/{sha256} (T2, admin) — withdraw a pin
GET /audit is the journal of everything at T2 and above, plus the admin surface
(/auth/keys* and /audit itself), refused attempts included. So it answers both “who changed the
set of keys” and “who ran this project, executed this action, started this task”. Each host keeps
its own log and answers only for itself, so query both ports for the full picture. T0/T1 reads
and edits are deliberately left out; see security-model.md for why, and for
what the peer and unit-of-work fields are worth. Each record carries the timestamp, the operation
id, the host, the key’s id/label (never the token itself), the method, path, status code, tier,
required scope, duration, peer address and the caller’s X-Zenno-Uow-Id if it sent one; filter with
?since=/?until= (ISO-8601), ?keyId=, ?method=, ?statusCode=, page with ?skip=/?take=.
GET /auth/keys lists every key record with systemProvisioned (auto-issued by the host for
its own built-in MCP servers — you did not create those, and the “My keys” UI hides them) and
status (active, expired or disabled). An expired/disabled key already fails
authentication but stays listed until revoked — revocation removes the record physically and is
irreversible. Filter with ?includeSystemProvisioned=false and ?status= (active, expired
or disabled).
GET /code-api (ProjectMaker host, code:read) serves the C# API surface available to
OwnCode cubes, from the product’s embedded code-api digest. Quick recipe:
# index of available types
curl -H "Authorization: Bearer <api-key>" http://localhost:5299/api/v1/code-api
# members of one type
curl -H "Authorization: Bearer <api-key>" "http://localhost:5299/api/v1/code-api?typeName=IZennoTable"
/capabilities
GET /api/v1/capabilities returns, for the key on the request (or an anonymous view if called
without one — see below):
{
"host": "zennoposter",
"version": "1.5.0",
"productVersion": "7.9.3.0",
"currentScopes": ["task:read", "task:control"],
"currentMaxTier": 2,
"operations": [
{
"operationId": "session_complete",
"method": "POST",
"path": "/api/v1/sessions/{id}/complete",
"tier": 2,
"requiredScope": "task:control",
"isAvailable": true
}
],
"tierLegend": { "T0": "read", "T1": "edit", "T2": "execute+audit", "T3": "host/RCE" }
}
host tells you which surface you’re talking to (zennoposter serves ZennoPoster+Instance+
cross-cutting operations; ProjectMaker serves ProjectMaker+Instance+cross-cutting — the two
hosts do not expose the same operation set). isAvailable is true only when the operation is
both implemented on this host and allowed for your key (scope + tier) — a
false entry can mean either “not yours to call” or “not built yet on this host”; check
requiredScope/tier against your own currentScopes/currentMaxTier to tell which. Calling
/capabilities before attempting a call is the cheapest way to avoid a predictable 403/501.
version is the contract version this host serves and productVersion the product build behind it,
so one call tells you what you are talking to; an empty productVersion means the host does not
report one. compatibility.md maps both onto the MCP server releases.