Skip to content

SER-6025: Add agents commands for the public Agents API - #30

Open
Priyanshu (priyanshu-plivo) wants to merge 14 commits into
mainfrom
feat/agents-api-commands
Open

SER-6025: Add agents commands for the public Agents API#30
Priyanshu (priyanshu-plivo) wants to merge 14 commits into
mainfrom
feat/agents-api-commands

Conversation

@priyanshu-plivo

@priyanshu-plivo Priyanshu (priyanshu-plivo) commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

What

Replaces the coming-soon agent stub with a real command group for the public Agents API.

Command
agents list --limit --offset --name --state
agents get <id> returns the agent plus its node graph
agents create --name --description --file <flow.json>
agents update <id> --name --description --file <flow.json>
agents delete <id> destructive — refuses without --yes
agents runs list <id> --limit --offset
agents runs get <id> <run_id> run detail plus logs
agents nodes list node-type catalogue
agents nodes get <node_type> full JSON Schema for one node type

--file takes a JSON document holding the flow graph (nodes, connections, optionally name/description) so a generated flow can be piped straight in. Flags override file values.

The CLI always targets production

An earlier revision of this branch added a --api-url / PLIVO_API_URL override. It has been removed and will not ship.

A runtime base-URL override on a published CLI is a credential-exfil vector: anyone who can get a user to set that env var — a copy-pasted export line, a poisoned shell profile, a "try this" snippet — receives that user's auth_id and auth_token on their next command, with no visible failure. The convenience is not worth it; pointing at a non-production gateway is a local-build concern.

Consequence for testing: the agents commands cannot be redirected at runtime, so HTTP-shape coverage lives in internal/api, where a Client's BaseURL is settable directly without exposing a redirect to end users. That is also the package's existing convention for HTTP tests. The command-level tests cover the argument handling that runs before any request.

API behaviours surfaced deliberately

  • Create returns the stored name, which may differ from what was requested — the backend appends " 1"/" 2" on a name collision. The command shows the returned name so the user sees what was actually created.
  • Validation errors surface verbatim. A flow whose nodes are not referenced by any connection returns a 422 naming the offending node ids; that message reaches the user intact rather than being flattened into a generic failure. Asserted by a test.
  • Connection endpoints are "<node_id>.<handle>"; a bare node id is rejected by the API. Subaccount credentials get 403. limit is capped at 20.

Verification

go build ./..., go vet ./..., gofmt and go test ./... -race all clean. Golden --help snapshots and docs/COMMANDS.md regenerated.

The full surface was exercised end to end against a non-production gateway with a locally-built binary before the override was removed: node catalogue (52 types), per-node JSON Schema, create with a graph, read back with nodes and connections intact, rename, runs list, destructive-refusal without --yes, delete, and 404 afterwards.

Note

internal/api/types_internal.go's Agent is renamed to PhloAgent to free the name for the public type. It sits behind //go:build internal and has no callers.

Two gofmt findings in cmd/voice_streams_*.go are pre-existing and untouched here.

Replaces the coming-soon stub with a real command group covering the public
Agents API: list/get/create/update/delete, runs list/get, and the node
catalogue plus per-node JSON Schema. A flow graph is supplied with --file so
a generated JSON document can be piped straight in; flags override file
values. delete refuses without --yes, per the destructive-verb contract.

Adds --api-url (and PLIVO_API_URL) resolved in getClient(), so the CLI can be
pointed at a non-production gateway. Precedence is flag > env > built-in
default; the value must parse as an absolute http(s) URL rather than silently
falling back, and a non-default base prints a notice to stderr so piped
output is never mistaken for production.

Verified against a non-production gateway end to end: node catalogue (52
types), per-node schema, create with a graph, read back with nodes and
connections intact, rename, runs list, destructive-refusal without --yes,
delete, and 404 afterwards. Server-side validation errors surface verbatim
rather than being flattened.
@priyanshu-plivo Priyanshu (priyanshu-plivo) changed the title Add agents commands for the public Agents API, plus an API base-URL override SER-6025: Add agents commands for the public Agents API, plus an API base-URL override Jul 27, 2026
The published CLI must always target production. A runtime override is a
credential-exfil vector: anyone who can get a user to set the env var (a
copy-pasted export line, a poisoned shell profile) receives that user's
auth_id and auth_token on the next command, with no visible failure.

Drops --api-url/PLIVO_API_URL, its resolution in getClient(), the README
section, and the tests that existed only to cover it.

HTTP-shape coverage for the agents commands moves to internal/api, where a
Client's BaseURL is settable directly without exposing a redirect to end
users. That is also the package's existing convention for HTTP tests. The
command-level tests keep the argument handling that runs before any request.

Golden help snapshots and docs/COMMANDS.md regenerated.
@priyanshu-plivo Priyanshu (priyanshu-plivo) changed the title SER-6025: Add agents commands for the public Agents API, plus an API base-URL override SER-6025: Add agents commands for the public Agents API Jul 27, 2026
Agent.ID was tagged `json:"id"` while the API publishes `agent_id`, so
`plivo agents list` and `agents get` printed an empty ID column against a
correct server. No test decoded a real list payload, so nothing caught it.

Also surfaces flow_type, which the API already publishes and which determines
which console route can open an agent -- it was being silently dropped.

Adds two tests: one decoding a real list payload, one asserting a bare `id`
key does NOT populate Agent.ID, so a server-side regression cannot hide behind
a CLI that still works.
Building an agent flow through the API has a few rules that are not guessable
and that a 201 will not warn you about. This packages them as a second skill,
distributed the same two ways as the CLI skill (embedded in the binary via
`plivo skill install agents`, or `skills.sh -s agents` with no binary).

The skill is built around discovery rather than a static reference: it tells the
model to read GET /AgentNode/{type}/ for schema + a working example instead of
inventing config, so it cannot drift as nodes change.

It leads with the six rules that actually break flows -- orphan nodes being
excluded from the version, connections addressing nodes by id while template
variables address them by name, endpoints needing <node_id>.<handle>, and node
ids being permanent -- and ships a dependency-free preflight script that checks
all of them offline. The id-vs-name rule is the one worth a script: it saves
cleanly and then resolves to nothing at runtime.

skill install keeps its previous no-argument behaviour exactly, and each skill
installs into its own directory so two files named SKILL.md cannot clobber each
other. --dir and --print are rejected with "all" rather than silently writing
one over the other.

Verified: the worked example creates, publishes and deletes against dev, and
the preflight script accepts valid flows while catching orphans, bare-id
endpoints, id-in-variable, duplicate ids and multiple starts.
A blind test of the skill (fresh agent, skill as its only documentation) built a
working 4-node branching agent first try, but surfaced a real gap: a source
handle that does not exist is accepted with 201, echoed back by GET unchanged,
and publishes to ACTIVE. The branch is permanently dead and nothing reports it.
Confirmed on dev with a handle of 'TOTALLY_MADE_UP'.

Naive validation would be worse than none: 'branch' mints a handle per
config.model.conditions[].alias -- the API's own example wires
'branch-1.vip_caller' -- and those aliases are absent from output_states. So
the preflight now checks handles against the live catalogue UNION the aliases
the node itself defines, and degrades with an explicit note when credentials
are absent rather than pretending to have checked.

That dynamic-handle rule was also missing from the skill entirely, which is a
notable hole in a document that teaches branching.

Other corrections from the same test:
- config.model was described as flat. True on write; http_request returns it
  nested under its own type key, and the server injects a 'name' it never sent.
- Publish/Pause/Resume had no status codes while every other row had one.
- The opening 'Contract' line told the reader to compare schema_version against
  a value it never gave. Removed.
- Trigger payload fields are not published anywhere, so the skill now says to
  read a real run or ask, and names the three paths the examples actually prove
  instead of implying any {{Start.*}} path works.
- Added the {{secrets.NAME}} namespace, the four terminal node types, branch
  layout coordinates, and a pointer to x-plivo-coverage.

Preflight matrix: 10/10, including the blind test's bogus handle and a valid
condition alias that must NOT be flagged.
Tracks the API changes made after review: agent_id -> agent_uuid, run_id ->
run_uuid, resource_uri on every object, Plivo's space-separated timestamp shape,
limit clamping rather than rejection, ?types= for fetching several node schemas
in one call, and "agent created" as the create message.

The substantive change is that node ids and canvas positions are now generated
server-side, so the skill no longer tells a caller to invent either. It explains
the id rule instead -- <type>_<n>, numbered per type in payload order -- because
connections still have to reference those ids, so a caller either predicts them
or reads them back. Two of the old rules collapse into that one; the permanence
rule survives but now applies to ids the server minted.

The preflight had to change with it. It now mints ids using the server's exact
rule before running any check, otherwise it would compare connections against
ids that do not exist yet and report every node as an orphan -- the same bug
this ordering just fixed in core, and the reason the script is worth keeping in
step with the backend rather than describing it loosely.

Preflight matrix: 13/13, covering the three new id-less cases plus all ten
earlier ones (orphans, bare-id endpoints, id-in-variable, duplicate ids,
multiple starts, bogus handles, and a valid branch condition alias that must
NOT be flagged).
The API moved agent_id -> agent_uuid and run_id -> run_uuid to match Plivo's
convention (the suffix follows the value's shape: uuid-valued ids get _uuid, as
in message_uuid and profile_uuid; numeric ones get _id, as in app_id). The
structs still said agent_id, so `plivo agents list` printed an empty ID column
against a correct server.

That is the second time this has broken the same way. First the struct said `id`
while the API said `agent_id`; now the reverse. Both times the tests passed,
because both times I had updated the mocked fixture to assert whatever the struct
happened to say -- the test verified the code against itself and confirmed
nothing about the wire.

So the fixture is now a REAL captured response from the live API, embedded from
testdata, and there is a test asserting that NEITHER `id` nor the old
`agent_id` can populate Agent.ID -- otherwise a server-side regression could
hide behind a CLI that still appeared to work.

Also decodes resource_uri, which Plivo publishes on every object.

Verified by pointing the real Client at dev and decoding a live list response,
not just the fixture: id, name, flow_type and resource_uri all populate.
The public API's resource segments are AgentFlow and AgentFlowNode, so
AccountURL now builds those paths. Renames the AgentNode types to match, and
updates the bundled agents skill so generated calls target the right paths.

No behaviour change beyond the paths: request shapes, response decoding and the
agent_uuid / run_uuid field names are unchanged.
…rift

left/top were float64 with omitempty, so Go could not distinguish "user wrote 0"
from "unset" and stripped a deliberate 0 before the request was built. The server
checks `is None` specifically to honour 0, so the struct tags were undoing that.
Now *float64: nil omits, &0 sends 0.

The captured fixture still said /Agent/ after the rename, and its test only
asserted the fields were non-empty -- any string satisfies that, so the exact drift
the fixture exists to catch sailed past it. It now asserts the path shape and the
trailing slash, and fails if the resource segment moves again.

SKILL.md contradicted itself and the code: it correctly said limit>20 is silently
clamped, then two hundred lines later listed 400-on-limit>20 as an error case. An
LLM following the table would write a retry path that never fires.

mergeAgentFlowFile extracts the --file merge block that create and update had
verbatim; create still tracks the requested name, update discards it.
@ashishrai-plivo

Copy link
Copy Markdown

First proper pass on this one, at 363cc92. The binary itself is in good shape — I checked the things that matter and they hold: the base-URL override is genuinely gone from every reachable path in the public build (AdminBaseURL is //go:build internal-gated and only wired to BuddyAdminURL, never AccountURL), the routes match hodor's registrations byte-for-byte including the trailing-slash convention, --yes runs before any network call, and nothing prints auth headers even at debug. Build, go test -race, and golangci-lint are all clean at head.

Two things need fixing before this merges though.

The bundled skill reinstates the exact vector the binary removed. agents-skill/SKILL.md's preflight script does

BASE = os.environ.get("PLIVO_API_BASE", "https://api.plivo.com")

and then sends Basic Auth to whatever that resolves to. The PR body's own argument for deleting --api-url — a copy-pasted export line or poisoned shell profile silently redirects credentials — applies more strongly here, because this script is meant to be executed autonomously by LLM agents, the audience most exposed to a poisoned env or prompt injection, with no human watching the request go out. And it landed in 7446d98, the hardening commit. I'd hardcode https://api.plivo.com in the script; pointing at a non-prod gateway is local-build territory, same as you concluded for the CLI itself.

docs/COMMANDS.md fails the repo's own drift gate. make docs at head produces a real diff (22 insertions / 15 deletions) in the skill install section — this PR's own cmd/skill.go changes (the [cli|agents|all] selector, the second destination) never got regenerated, and CI runs exactly make docs && git diff --exit-code docs/COMMANDS.md. The Agents section further down was regenerated fine; it's just that one section.

One gap that undercuts the feature: there's no agents publish / pause / resume, but hodor exposes all three and the skill's own "workflow that works" pivots on Publish to move DRAFT→ACTIVE. As shipped, plivo agents create leaves you stuck in draft unless you drop to raw curl — which is the thing this CLI exists to replace. Even just agents publish <id> would close the loop.

Smaller stuff, take or leave:

  • The Left/Top*float64 fix is right, but no test marshals Left: ptr(0) and asserts "left":0 survives — the captured-response fixture guards agent_uuid/ResourceURI drift, not this. A future "simplify to plain float64" reintroduces the exact bug silently.
  • The captured-real-response fixture is a good pattern — worth extending to AgentRun and AgentFlowNode, which carry the same field-naming risk the test file itself says already bit Agent twice.
  • --all ("auto-paginate through all pages") shows up on the new agents list help but nothing anywhere consumes allFlag — pre-existing, but now user-visible on commands where >20 results is realistic. Either wire it or suppress it here.
  • --explain is advertised on every agents subcommand but only wired in runAgentCreate.
  • readAgentFlowFile's invalid-JSON branch has no test (only missing-file).

The AgentPhloAgent rename checks out — zero callers, both builds compile.

The bundled skill reinstated the exact vector the binary removed. Its preflight read
the base URL from PLIVO_API_BASE and then sent Basic Auth to whatever that resolved
to. That argument applies more strongly to the skill than to the CLI, not less: the
script is designed to run autonomously under an LLM agent with no human watching the
request leave, so a copy-pasted export line, a poisoned shell profile or prompt
injection silently redirects live credentials. Now a literal, with a comment saying
why it must stay one.

docs/COMMANDS.md was stale for the skill-install section, so the repo's own drift
gate (make docs plus git diff --exit-code) would have failed CI. Regenerated.
…gents

publish, pause and resume. hodor exposed all three and the bundled skill's own
workflow pivots on Publish, so without them create left you in DRAFT and forced a
drop to raw curl -- the thing this CLI exists to replace. One shared runner: the
three differ only by a path segment and their wording, so three copies would be
duplication. They send no body, which is why core's handler opts into
ALLOW_EMPTY_BODY.

--all was a declared-but-unconsumed root persistent flag, so every command
advertised auto-pagination and none delivered it. Wired for agents list, where the
server clamps limit to 20 and a silently truncated list is the worst outcome. Guards
against spinning if a page comes back empty before total_count is reached.

--explain was advertised on every agents subcommand but only honoured in create;
list and the lifecycle verbs now print their intended request too.

Tests for two branches that had none. An explicit left/top of 0 must reach the wire:
the server checks `is None` so a deliberate 0 is meaningful, and these are *float64
for exactly that reason. Reverting them to plain float64 makes the node marshal to
{"type":"send_message"} with both positions dropped, and nothing else would have
caught it. Also readAgentFlowFile's invalid-JSON branch, which is the likelier
mistake than a missing file and must name the path.

Golden help files and COMMANDS.md regenerated; make docs is idempotent, so the
repo's drift gate passes.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Both must-fixes verified at 8788946. The skill hardcodes https://api.plivo.com with the "hardcoded on purpose" comment, and I swept the whole tree — no env-sourced value feeds a URL that receives credentials anywhere. make docs at head produces zero diff, so the CI gate passes. The lifecycle verbs construct exactly the routes hodor registers (checked against its PR directly, trailing-slash form included), send the documented empty body, and --explain runs before any network call.

One thing I'd close before merge: --all got wired for agents list but not agents runs list. Same flags, same ListMeta/total_count shape, and the flag still shows in that command's help — so a busy agent with hundreds of runs silently truncates to page one under --all, which is the exact bug your commit message describes fixing. The list-side loop is good (advancing by actual objects returned handles the server clamp correctly); it just needs the same treatment one command over. (agents nodes list correctly has no pagination, that omission is fine.)

Smaller, take or leave:

  • Nothing tests the lifecycle verbs' execution path — the constructed URL, the empty POST, the response decode. The help-snapshot test proves they're registered, but a route typo would only be caught in production. registration_test.go's agents list also still reads {"create","list","get","update","delete"}.
  • The empty-response fallback in runAgentLifecycle builds the message as verb + "d" — fine for pause/resume, but publish becomes "publishd". Pass the past tense in explicitly.
  • agents pause has no --yes gate. Every other stop-a-live-thing verb in this CLI (voice calls hangup, conferences end, streams stop, masking sessions end) requires one. Pause is reversible so I can see it either way, but it's a deliberate break from the codebase's own convention — your call, just deciding it on purpose would be good.
  • The captured-real-response fixture still only covers Agent/AgentList; AgentRun and AgentFlowNode remain hand-authored JSON, which is the gap that let agent_uuid break twice before.
  • The --all loop has no max-iteration cap — a server that ignores offset and returns the same non-empty page loops forever. Unlikely against hodor, cheap to bound.

Also noticed the CI smoke workflow (dry-run URL assertions, destructive-refusal checks) was never extended to any agents subcommand, and its per-group help step still carries the "agent ships as a coming-soon stub" comment. Not this PR's regression, but the largest new command surface in the CLI currently has no binary-level smoke coverage — fine as a follow-up.

…l see it

The limitation was only in a core docstring, which is not where a customer-facing
person looks before a customer hits it. Two shapes cannot be restored on write and
are rejected with 400 rather than guessed: a list of bare secret strings (the
api_tokens shape, nothing distinguishes the elements so pairing would be positional
and a reorder would swap credentials), and two entries in one list sharing an
identity. Spells out the consequence too -- a node holding either shape cannot be
saved through a full-graph update at all until the real values are included, even
if nothing else changed.
Masking now covers the whole value under a secret-named key, including a nested
object's inner fields, so the round-trip note has to say so: an object list with no
identity field is as unrestorable as a bare string list.
Rule 8 promised that posting back exactly what you read always works. Verified on
dev that it does not: an http_request node's url and headers come back null after an
unmodified GET-POST round trip, under a 202. send_message, which has no
AS_DICTIONARY rendering group, round-trips losslessly -- so the promise held only for
the node types that happen not to nest. Says what actually happens, names the
affected types, and gives the workaround until it is fixed server-side.
@ashishrai-plivo

Copy link
Copy Markdown

Checked both doc commits against the server's final behaviour at contacto-core#1302's head — the limitation table is accurate on everything load-bearing (unique-identity pairing, the atomic 400, the exact error path, the remediation). Two one-line accuracy nits worth fixing since agents will follow this file literally:

  • Row 1 of the round-trip table lists the identity keys as id/uuid/key/name, but the server's _LIST_IDENTITY_KEYS (and your own row 2) include node_type — an object list whose entries carry only a unique node_type actually does restore.
  • "Two cases it cannot do that for" is really three: config nested deeper than 50 levels gets its own distinct 400 ("Flatten it, or send the node's config explicitly…"). Practically unreachable for real configs, but the message differs from the two documented ones, so a caller who hits it won't find it in this table.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants