The manifest — workpeg.json¶
Everything the platform needs to know about your Peg that is not code. One file, a handful of named sections, and the platform reads the ones it knows and ignores the rest — so an old Peg and a new platform interoperate, and so does the reverse.
{
"namespace": "contacts",
"search": { ... }, // how your rows read in universal search
"actions": [ ... ], // what Workpeg AI may do with your Peg
"renderers": [ ... ], // views of your data for Workpeg AI (dormant)
"skills": [ ... ], // instructions a person can add to a chat
"summary": "...", // one line saying what your Peg is for
"dev": { ... }, // whether others may run dev pages on it
"scopes": [ ... ], // platform surfaces beyond tables it asks for
"function": { ... }, // only if you ship one
"runtime": { ... }
}
Sections are independent. A Peg with no function omits function and
runtime entirely — and most Pegs should, for the reason in
Actions below.
renderersare declared, validated and published, but nothing on the platform calls one yet. Declaring one costs nothing.skillsare covered in Skills.summaryis one line, at most 200 bytes, that describes the Peg. It is shown to Workpeg AI as the Peg's own claim. It must describe the Peg, not give orders: a summary with "you", "always", "never", "must" or "do not" fails the build.devis covered indevbelow.scopesis covered inscopesbelow.
workpeg ui build copies every section but function and runtime into
dist/manifest.json, which you upload to the CDN. A Peg that declares none
of them gets no manifest.json.
namespace¶
The Peg's identity. It scopes your table aliases (<business>::<namespace>::<alias>),
prefixes the tool names the model sees, and decides which manifest the platform
fetches. Letters, digits, dots and hyphens.
Changing it is not a rename — it is a different Peg.
search¶
How a row of yours reads when it turns up in universal search — and where an action's result says to look.
"search": {
"label": "{haystack} · {table.name}",
"link": "{workspace}/tables/{table}",
"icon": "address-book"
}
Placeholders: {haystack} {table} {table.name} {row} {business}
{peg} {namespace} {domain} {workspace}.
link does double duty, and this is the cheapest line in this file. Every
action result carries it rendered, so a reply can say "moved it to
In Progress" and hand the person a URL that opens the board — instead of
naming a card and leaving them to go and find it, which was also the only way
they could check the card had moved at all.
A chat link is stricter than a search hit's: absolute only, it must contain
{table}, and only ids substitute ({table} {row} {business} {domain}
{workspace} — nothing free-text goes into an href a model will relay). Break
one and nothing breaks: the platform falls back to its own table viewer, so
there is always somewhere to look. Full rules in
Search representation.
dev¶
Whether businesses other than yours may run a
dev page on your Peg's namespace, at
https://<namespace>.dev.workpeg.com:
- No
devsection means allow. Anyone building on top of your Peg can run a dev page on its namespace, with their own test tables, in their own business. "deny"refuses every business but yours. Your own business is never refused by this setting, but it must be a developer business, as every dev page's business must (Before you start)."others"is the only key, and it takes exactly"allow"or"deny", in lower case. Anything else fails the build, by name.{}fails too: leave the section out to allow.
It takes effect when you upload the built manifest.json:
The registry reads dev from that upload. It reads a dev section it
cannot understand as "deny", which is why the build refuses one. Deleting
the uploaded manifest.json sets it back to allow. Only the owner of an
approved Peg can upload, so a Peg still in review allows others until it is
approved and its manifest is uploaded. Workpeg's operators can also set it
by hand in the registry, for a Peg whose manifest they upload themselves.
scopes¶
Which of the platform's surfaces beyond tables your Peg's page asks to reach:
| Name | Opens |
|---|---|
calls |
people/ and signals/: who works here, and call set-up (Calls) |
files |
the business's Drive |
mail |
the business's mail admin |
- Declaring a scope is half of it. Workpeg also has to grant it to your
Peg. A page reaches a scope only when both are true, and gets
"scope-not-declared"otherwise. - A manifest without
scopeswithdraws every scope. The platform readsscopesfrom the uploadedmanifest.json, within five minutes of the upload, and a manifest that has none declares none. So keep the section inworkpeg.jsonfor as long as your Peg uses it, and upload themanifest.jsonthe build wrote, which carries it. "scopes": []is kept in the built manifest, so it is how a Peg withdraws its scopes on purpose.- The section is a list of the exact names above, in lower case, each once. Anything else fails the build, by name: the platform would drop a name it does not know without a word.
Actions¶
An action is a promise to a model: each one becomes a tool offered on turns involving your Peg. Workpeg is a uniform table structure first, and the shape of this block follows from that.
{
"name": "list_contacts",
"title": "List contacts",
"description": "Lists the contacts saved in this workspace.",
"risk": "read",
"via": {
"table": "contacts",
"operation": "list",
"fields": { "name": "name", "email": "email", "phone": "phone_no" }
},
"parameters": { "type": "object", "properties": {} }
}
| Key | Required | What it is |
|---|---|---|
name |
✓ | lowercase letters, digits, underscores. ≤ 64 chars. Becomes part of the tool name. |
title |
✓ | shown to a person. ≤ 80 chars. |
description |
✓ | shown to the model. ≤ 400 chars. |
risk |
✓ | read, write, exec, external — see Risk. |
via |
✓ | what this action actually does — see below. |
parameters |
✓ | JSON Schema. Ignored for an operation — see Parameters. |
confirm |
a sentence for an approval prompt — carried, not yet rendered; see below. | |
returns |
free-form description of the result. |
Up to 24 actions per Peg, and 64 across all Pegs in one workspace — every one is described to the model on every turn, so the ceiling is a context window and a bill, not storage.
Two forms of via¶
An action either is a table operation the platform performs, or it rides a save hook into your function. Declaring both is refused rather than resolved by precedence: which one won would decide whether your container ever runs, and a reader would have to know the tie-break to know what their Peg does.
The operation form — preferred¶
The platform performs it directly, as the person who asked, through the same permission path the UI uses. Nothing is deployed, nothing is credentialed, nothing can leak — there is no container in the path at all.
operation |
Does | Needs |
|---|---|---|
list |
returns rows, optionally filtered and paged | — |
get |
returns one row | id |
create |
writes a row, then dispatches the table's post-save hook | at least one mapped field |
update |
changes the given fields of one row, then dispatches the same hook | id and ≥1 mapped field |
delete |
removes one row — no hook is dispatched | id |
compose |
creates a TABLE from an abstract, then its first row | see compose |
add_choice |
adds one choice to a column — changes the table's schema | exec risk, no fields |
share |
gives ONE named person access, or takes it away | exec risk, scoped only |
get, update and delete name one existing row, so those three — and only
those three — may also carry key.
The phase form — for what an operation cannot express¶
table is required here too: the alias is what the platform resolves and
authorises the caller against before your function is reached, which is what
makes a phase action checkable at all.
Your function is invoked. Reach for it for tasks, complex integrations, and out-of-the-box automations — work that talks to a third party, retries, or computes rather than maps.
phase is a label, not a trigger, and this surprises people. The action
calls your function's /invoke directly, the moment the model calls the
action. It runs whether or not that table has a save hook of that phase — or
any save hook at all. What phase does is tell your function which shape of
payload to expect, arriving as a string in the context; it does not subscribe
the action to anything. The name is historical: it reuses the same entrypoint a
real save hook calls, which is why no second endpoint was needed.
If you want work that happens because a row was written — by anyone, through
any path, not only when the model calls your action — that is a real
save hook attached to the table, not a phase action.
The dividing line, stated once: creating a row is an operation; sending the
mail afterwards is not. A declarative create dispatches the table's
post-save hook, so the row and the side effect are one declaration — the hook
does the part an operation cannot.
But not one outcome, and this changed. The post-save hook is dispatched and
not awaited: the write returns as soon as the row is committed, so the result
carries "dispatched": true and no success flag. Your action's reply can say
"added them"; it cannot say "added them and sent the mail", because at that
moment nobody knows. If the caller must know the side effect succeeded, it is
not a post-save hook — it is a pre-save hook (which blocks, and can refuse the
write) or work your function does itself. See Save hooks.
fields¶
{ the name your action uses: the column name in the table }.
This mapping is the reason an operation needs no code. Declared once here, it replaces a container re-deriving the same thing on every call.
- Keys become parameter names the model sees, so they are held to the same shape as an action name (lowercase, digits, underscores).
- Values are column names in somebody's table. You do not control them as tightly and the manifest is written before any table exists, so they are length-capped (64 characters) and stripped of control characters rather than refused for them. A column that turns out not to exist is dropped, not fatal — the same manifest serves every business that installs your Peg, and a workspace whose table predates a column is ordinary. If nothing maps, the action is refused: answering with empty rows would look like data.
idis reserved, here and inroles. Every row comes back with itsid, because that is how a later action names it — and every operation that addresses a row offersidas the handle. Mapping a column onto it is a build error on either key. As a role it is the one worth picturing: the model sends a row's id to say which row, and the same argument lands in the role's column, so an update renames a card to its own uuid and reports success.- Up to 32 columns per action.
key — addressing a row by name¶
"via": {
"table": "tasks",
"operation": "delete",
"fields": { "title": "Task", "status": "Status" },
"key": "title"
}
key names one of your own fields — or one of your
roles, which is usually
what you want, since a card is addressed by its title. That field's exact value may then
address a row, alongside its id.
Reach for it whenever you convert a function action to an operation. A
handler you wrote almost certainly accepts "the card's exact title, or its row
id". Every operation takes an id, so converting without a key silently
narrows the action: every call becomes a list-then-act pair, and every title
the model already remembers becomes "no row with that id". Nothing errors —
it just gets worse.
It does not add a parameter. The generated id argument widens to accept
either, and its description becomes "The row's id … or its exact title." Two
arguments would let a model send both and mean two different rows.
Three rules worth knowing before you rely on it:
- An id is always tried first, and a number never falls through to a name.
If your key column holds numbers,
"12"means row 12 — never the row titled "12". - Ambiguity is refused, never guessed. If two rows share the value, nothing happens and the matching ids come back instead. Two cards can share a title, and picking one would delete a row nobody named.
- It is not a way around a permission. The lookup runs over the same caller-scoped queryset an id lookup uses.
Only on get, update and delete. list addresses no row, and
create/compose make one — a key on any of them is a build error.
Version note.
keyneeds a platform that understands it. An older platform drops the whole action rather than ignoring the key — see When your action does not appear.
roles — let the table say what a column is for¶
"via": {
"table": "tasks",
"operation": "create",
"roles": { "title": "representation", "status": "grouping" },
"fields": { "due": "Due" }
}
A table records how it can be looked at in metadata.render_types — which
column holds a card's title, which holds its status. roles asks for the
column by its role and gets whatever that workspace actually named it.
Two roles exist: representation (the title) and grouping (the status).
Prefer this over naming the column in fields. A manifest saying
{"status": "Status"} is English-only the moment somebody provisions a board
in French, and stale the moment an owner renames a column. A role survives
both.
Roles become arguments exactly like fields, so the model can set them. A name
cannot be both a field and a role — one name resolving two ways would decide
which column a value lands in by whichever check ran last. For the same
reason a role cannot be called id: see
the reserved name.
A role that will not resolve refuses the action, where a missing fields
column is merely dropped. The asymmetry is deliberate: a dropped field means
"this table predates a column", which is ordinary. An unresolvable role means
this is not that kind of table, which is a different statement and should
not be silent.
Roles also bring two rules on create:
- the
representationfield is required — a row without the thing it is read by is not a partial row, it is an unreadable one; - a
groupingfield nobody supplied takes the column's first choice, so a card with no status lands in the leftmost column rather than in none.
scope — an action over a table the model picks¶
Every form above binds one table by alias. scope is the form that does
not: your action declares what it may do, and the model says which table,
per call, out of the ones your Peg may reach.
This is what "add a card to my Travel board" needs. A bound action can only reach a board somebody aliased in a manifest first, so a board a person made this morning is invisible to it.
scope takes exactly one value, "peg", and that is deliberate. It
declares the shape of the reach — a table chosen when the action runs — and
never which tables. Which tables is a platform fact, held in the database
and read when your action runs. A manifest is fetched from the CDN by name,
with no version or signature, on a short cache; a reach declared there could
be widened after somebody installed you, silently. So the manifest says what
shape, and the platform says how far.
By default that is your Peg's own tables. The workspace app reaches the workspace's own peg-less tables too — a superset an operator grants, never something a manifest can ask for.
A scoped action takes no table, abstract, phase, fields, key or
filters: each of those describes one table you knew in advance. The
platform generates the arguments instead — a table id from the platform's own table.list, a values object keyed by the chosen table's own column
names, and the usual query/offset/limit on a list.
A scoped write must carry a confirm naming its target, and
{table_name} is available in it — resolved by the platform from the table it
just checked, never sent by the model. Without it the approval card shows only
your action's title, and "Add a card" does not tell anybody which board.
requires — the shape a chosen table must have¶
["representation", "grouping"] means this action is for boards. The
platform reads it from the chosen table's own metadata.render_types, so the
check works on a table your manifest has never seen — which is exactly what a
scoped action needs, and what naming a column literally could never give you.
A list, not the {name: role} map roles
uses: a map would put two namespaces over one argument, and a table with a
column genuinely called status would need a tie-break.
create_table — make one¶
{
"name": "create_board",
"risk": "exec",
"confirm": "Create a board called {name}?",
"via": { "scope": "peg", "operation": "create_table" }
}
Makes a table in your Peg's namespace. The columns come from the person's
own sentence at call time — a name, a list of {name, type}, and a render
type — not from your manifest. A manifest written once for every business that
installs you would be English-only the moment somebody asks for a board in
French, and the platform shipping a template would be the same defect promoted
to global.
exec, not write, and the build refuses anything else. It writes a
schema, so it asks under every preset but full access — which is the only real
bound on an assistant making tables nobody wanted.
Inheriting a schema instead¶
Add via.abstract and the new table takes its columns from a template rather
than from the model:
The table borrows the schema through a link rather than copying it, the same way a compose does — so a later change to the template reaches every table built on it.
Which form to use is a real choice, and the paragraph above is half of it.
Inherit when the schema is machine-meaningful and fixed by whoever minted
it — _email's columns are RFC field names a hook reads by id, and nobody
renames them. Declare when the columns are labels a person reads, because
those must arrive in the person's own words and language. The inheriting form
is not the better one; it is the one for schemas that are not the person's to
name.
The worked example is a Peg that declined it. Workpeg's own workspace app visualises whatever tables a workspace already has; its trackers are boards whose columns somebody names and renames. So it uses the declaring form even though the inheriting one was built for it — the columns are the person's, and an inherited table cannot be a board anyway. If your Peg's tables are shaped by the people using them rather than by your code, that is your answer too.
An inherited table pins its template
The link is RESTRICT: while any table inherits a template, that template
cannot be deleted. There is no chat path to delete the inheriting table
either. And a later change to the template reaches rows already written —
adding a required column can make existing rows unsaveable. That is the
point for a platform-fixed schema and a surprise for anything else.
Three things it does not do, each for a reason worth knowing:
- It cannot be a board. An inherited table's columns are the template's,
so it has no status column of its own to group by — and a board that could
never gain a status is worse than a refusal.
renderis capped attableanddocument. - It takes no
columnsargument. The model is not offered one, and a call carrying one is refused rather than merged. A table holding both its own columns and a link would ignore the link in silence. - It resolves a BARE alias only. A template your app created through the Tables API is stored qualified and cannot be named here, so in practice this reaches operator-provisioned templates. A template that is out of reach and one that does not exist give the same message, so a manifest cannot probe which templates a deployment has.
share — give one person access¶
{
"name": "share_tracker",
"risk": "exec",
"confirm": "Give {person} {access} access to {table_name}?",
"via": { "scope": "peg", "operation": "share" }
}
Writes ONE individual grant, naming one person by their email address.
access is view, edit, or none to take it back.
It cannot touch the business or public digits, and no manifest key or argument can reach them. Those two reach everybody at once — every colleague, and for the public digit every guest — and the platform will not let a model move a boundary that wide. An individual grant is the opposite kind of act: it names a person, so the consent card can say who, and the same action reverses it.
Scoped only. The table it shares is an argument, so there is no bound form.
The platform allows it only for the table's OWNER, mirroring the rule the Tables API already applies to individual grants. So the assistant can never widen reach on a table the person merely has access to.
There is no directory. An email address has to come from the person asking — the model has no way to list who works at a workspace, and a share naming somebody who is not there reads exactly like one naming somebody in another workspace, so it cannot be used to find out who has an account.
A share is not announced
Nobody is notified that they were given access. And if search is enabled, a table somebody can read is a table whose rows they can find by searching — sharing is not only "they can open it if I send them a link".
filters — narrow a list by an exact value¶
"via": {
"table": "tasks", "operation": "list",
"roles": { "status": "grouping" },
"filters": ["status"]
}
Each name must be one your action already maps — a field or a role. Every filter becomes an optional argument matching exactly.
No operator and no value. What matching means follows the column's type,
which is the platform's business. A manifest that could write due__lt would
be composing database lookups from a file served off a CDN.
Filters compose with query rather than replacing it. query is a
substring scan across every mapped column; a filter is an exact match on one.
"Cards in progress mentioning the invoice" needs both — and query="done"
also matches a card titled "done the washing", which is why a status filter
cannot be spelled as a query.
A choice column's filter takes the word a person reads: send "In Progress",
not the in_progress underneath.
Only on list. At most 8.
add_choice — add an option to a column¶
{ "name": "add_status", "title": "Add a status to the board",
"risk": "exec",
"via": { "table": "tasks", "operation": "add_choice" },
"parameters": { "type": "object", "properties": {} } }
The only operation that changes a table's schema rather than its rows.
It takes no fields, and does not name the column. The target is the
grouping column from the table's own render metadata, which the platform has
already type-checked to a select.
It must be declared exec, not write. Changing a column changes what
every existing row means — a different question from "may this person edit one
row", and the permission gate keys on risk.
The model is offered name (required) and color (optional, #RRGGBB). The
stored ref is derived from the name and is never an argument: a ref is
what every row's cell holds, so a caller choosing one could collide with an
existing choice and silently re-point rows nobody touched.
Duplicates are refused case-insensitively, on both the ref and the visible name.
Risk¶
| Class | Means |
|---|---|
read |
returns data, changes nothing |
write |
changes workspace data |
exec |
runs something |
external |
reaches outside the workspace |
A create, update, delete or compose must carry write or exec.
This is a permission rule wearing a validation's clothes: the platform's gate
keys on risk, not on the operation, so {"operation": "delete", "risk":
"read"} would be dispatched without ever testing the caller's write
permission. The manifest is not allowed to describe that shape.
Over-declaring the other way is not free, which is worth knowing before
you do it "to be safe": a read operation declared at write risk is put
through the write gate, so a caller who may read the table but not change it
has the action refused outright. Declare the risk the operation actually
carries.
parameters¶
Required on every action, and ignored for an operation: the platform generates the schema, because the platform implements the arguments.
That is not pedantry. A Peg that declared {"assignee": ...} on a list would
produce a tool the model calls with an assignee nothing reads — and it would
get the whole table back as a confidently filtered answer. Generating the
schema makes the names the model sees and the names the executor reads the same
names by construction.
For a phase action, parameters is the contract: your function receives
them as the payload. ≤ 4096 bytes serialized.
confirm¶
Validated at build and carried in the manifest, but the platform does not
render it today — the approval prompt a person sees is built from the
action's title. Write it if you want it to be right when that lands; do not
rely on it being read.
Its placeholders are checked at build against arguments the action really
takes: for an operation, the mapped field names plus id; for a phase
action, the properties of your parameters. That check exists so a
placeholder cannot render literally in a sentence somebody is asked to
approve — a rule worth keeping even while nothing renders it yet.
The platform truncates it to 80 characters on ingest (not the 200 the build allows), so a long one is silently cut.
Paging a list¶
A list action is given offset and limit automatically. You declare
nothing.
Results are capped at 200 rows. When there are more, the result says so and says how to continue:
{
"rows": [ ... ],
"count": 200,
"truncated": true,
"next_offset": 200,
"note": "Showing rows 1-200; there are more. Call this again with offset=200 …"
}
Passing offset back walks an unfiltered list with no gaps and no
repeats, because the ordering is total — by position and row id. (Position
alone is a float callers set freely, so "the first 200" would name a different
set between two identical calls, and paging built on it would skip rows
silently.)
A filtered list (query) pages the same way, with one caveat: matching
happens in Python over the scan budget below, so a page deep into a large
table re-scans from the start and can hit that budget before it reaches your
offset. Narrow the query rather than paging far into one.
Two other bounds worth knowing, because they change what a result means:
- A filtered
liststops after examining 5,000 rows and says so. That is a bound on work, not on matches — "no matches" for a table it never finished reading would be a wrong answer rather than a partial one. - Each returned cell is capped at 500 characters and truncation is visible in the value. A silent cut reads to the model as the whole value, and it will answer as if it were.
compose¶
Some Pegs model a thing as a table, not a row. A mail is a thread; a board
is a table of cards. Sending mail creates a table from the _email abstract
and then writes the row whose save hook sends it — so "create a row in the
emails table" cannot express it, because there is no emails table. There are
thousands, one per thread.
{
"name": "send_email",
"risk": "write",
"via": {
"abstract": "_email",
"operation": "compose",
"fields": { "subject": "subject", "to": "to", "body": "plain_text" }
},
"parameters": { "type": "object", "properties": {} },
"confirm": "Send an email to {to}?"
}
It names the abstract it inherits from — stable and aliasable — rather
than the table it creates, which does not exist yet. table and abstract are
mutually exclusive.
Its generated arguments are the mapped fields, exactly as create's are: a
compose writes a row like any other write, it just makes the table first.
The hook comes from the template¶
create and update fire the hook attached to the table they write to. A
composed table is brand new, so it used to have none — the row landed and
nothing followed it, which for mail meant a thread was filed and no mail was
sent, reported as done.
A compose now copies the abstract's save hooks onto the table it creates. If your template carries one, every table composed from it carries the same one, and the first row fires it.
Your manifest cannot name the hook, and that is deliberate. The platform
resolves a hook slug by attribute lookup, so whoever chooses the string
chooses which built-in runs — send_email is one lookup away from every
other. The hooks come from the template, which only an operator can
provision, so a Peg composing from a shared template gets the hooks an
operator put there and cannot ask for a different one.
The hooks are copied at compose time, not followed at run time. Tables
that already inherit your template are untouched: a mail thread that arrived
in the inbox inherits _email and carries no send hook on purpose, and a
run-time lookup would have made editing a received mail post it back out.
namespace — the folder the new row lands in¶
"via": {
"abstract": "_email", "operation": "compose",
"fields": { "to": "to" },
"namespace": "sent"
}
A composed row can say which folder it belongs to. Mail is the case it exists
for: outbound belongs in sent and received mail in inbox, and the
platform does not know that — it carries no knowledge of a particular Peg.
The template cannot carry it either, because inbound and outbound threads
inherit the same one and differ only in their rows.
It is a label, not a fence: nothing grants or denies on it, it groups rows
for the app that reads them. It belongs to compose alone — every other
operation writes into a table that already exists, and how that table groups
its rows is not one action's to redefine.
An abstract pinned to a Peg may only be inherited by that Peg. Only a bare, operator-minted alias on a table with no Peg and no owner is a shared template any Peg may build on.
Everything here is untrusted¶
Your manifest is third-party text that reaches a model's context window and a person's screen. The SDK validates at build — which is where you want to hear it — and the platform validates again on ingest, because the file it reads is whatever is at your CDN URL now.
Anything that fails is dropped rather than repaired: a half-understood action is one nobody can predict. Lengths are capped, control characters stripped, and nothing is interpolated anywhere it could read as an instruction from the platform — your description is quoted as your Peg's claim about itself.
A workspace's assembled action list is cached for 15 minutes, so a manifest change shows up within that without anybody restarting anything.
When your action does not appear¶
The platform never rejects a manifest. It drops what it dislikes, action by action, and serves the rest. There is no error, no 4xx, no field in any response saying an action was refused. The Peg simply does not have that capability, the model is never told it exists, and the turn runs normally.
That is deliberate — one bad action must not take a Peg's other twenty-three down with it — but it means a silent drop is the failure mode you will actually meet, and the symptom is always the same: the model behaves as though your action was never written.
So: run the validator in your build. It is the only place any of this is loud.
It never appeared at all¶
Most causes are shape errors the validator catches first. These are the ones it cannot:
| Cause | Why the validator misses it |
|---|---|
| More than 24 actions in your manifest | Everything past the 24th is sliced off without a word. |
| More than 64 actions across every Peg in one workspace | A workspace ceiling, not a Peg one. Pegs are walked in alphabetical order by name, so whether your action survives depends on what else that customer installed. |
| The generated tool name is over 64 characters | The model calls it peg_<namespace>.<action_name>. The 64-character cap applies to that whole string, and neither the SDK nor the platform budgets for the prefix — so a legal 64-character action name in a Peg called acme.warehouse is dropped at registration. Keep namespace + name comfortably under ~45. |
| Another Peg already owns that tool name | Two Pegs, one namespace. The second is dropped rather than bound to the first Peg's handler. |
| The name collides with a built-in platform tool | Same outcome. |
PEG_MANIFEST_CDN_BASE is unset on that deployment |
No manifests are fetched at all. Every Peg has zero actions. |
| The manifest could not be fetched or parsed | A CDN blip or a JSON error contributes nothing, silently. Your workpeg.json must be valid JSON at the URL, not just on disk. |
It appeared, then stopped — or a fix has not landed¶
The assembled action list is cached per chat for 15 minutes, and it is cached even when it is empty. So a manifest you just fixed can take up to a quarter of an hour to appear in a chat that is already open, and a chat that loaded during a CDN blip stays empty for that long. Start a new chat to check a fix rather than concluding it did not work.
It appears, but every call fails¶
The action survived ingest and is failing at run time. The usual cause is
fields: a mapped column that this workspace's table does not have is
dropped, not fatal — the same manifest serves every business that installs
your Peg, and a customer whose table predates a column is ordinary. But if
none of your mapped columns exist there, the action refuses with a message
naming your Peg and the table. That is a manifest describing a different table.
Reading the platform's side¶
Every drop above is either silent or a single INFO/WARNING line in the
platform's log, of the form peg <namespace> action <name> …. If you can reach
those logs, that is where the reason is. If you cannot, the validator plus this
list is what you have.
Validating yours¶
workpeg ui build ui/ # a project: workpeg.json at the root, main.py in ui/
workpeg ui build . # a UI-only peg: main.py beside workpeg.json
Point it at the directory holding main.py, not at the peg root — the
build looks for workpeg.json beside the entry file and then one directory
up, so both layouts find it, but . at the root of a scaffolded project
fails with no main.py found before it validates anything.
A manifest that fails here would otherwise have shipped and then silently offered nothing: the platform drops what it cannot understand.