Kodexa Project Resource (Binding) Authoring
The model
Most Kodexa resources are org-scoped: one activity-plan, one task-template, one document store,
shared by the whole organization. A project resource is a binding row in kdxa_project_resources
saying "this project may use that resource" — the set of bindings is the project's resource list.
A binding is (projectId, resourceType, resourceId). Three consequences worth memorising:
- That triple is unique, and the same resource can be bound to any number of projects. Re-binding
through
/bindreturns the existing row; re-binding through the plain create endpoint is a409. - The row stores a resolved id, not a URI. Renaming a resource's slug does not break its
bindings, and you cannot read a binding back as a
scheme://org/slugURI. - Binding does not copy. The org resource stays the single source of truth for every project
bound to it. (One exception: a project-template
taskTemplates:entry authored inline rather than byref:creates a separate template row and binds that copy.)
The binding call
POST /api/project-resources/bind
{
"projectId": "00000000-0000-4000-8000-000000000001",
"resourceType": "activity-plan",
"resourceUri": "activity-plan://acme-corp/invoice-posting"
}
- Send
resourceUri(resolved server-side against the project's organization) orresourceIdif you already hold the UUID. One is required. - Do not send
organizationId— it is derived from the project. - URIs are unversioned.
activity-plan://acme-corp/invoice-posting:1.0.0fails to resolve:version-suffixed URIs not supported. - Idempotent: an existing binding returns
200 {"status":"existing"}; a new one returns201. - Requires the
bindpermission onproject-resourcefor that project.
Unbind with DELETE /api/project-resources/{id}. It is a hard delete.
There is also a plain POST /api/project-resources (what the CLI and the UI actually call): no URI
and no resolution, so it needs projectId, organizationId, resourceType and resourceId.
Authoring bindings in YAML
In a kdx metadata-repo manifest — the linked: block
Anything under linked: is pushed at org scope and then bound. Org-scoped types listed inline
under the project are folded into linked: when the manifest loads, so they bind too — with the one
carve-out below. Project-scoped types (assistant, trigger) stay inline and never bind.
manifest_version: "1.0"
metadata_dir: metadata
organization:
activity-plan: [invoice-posting]
task-template: [invoice-review]
projects:
invoice-ops:
assistant: [invoice-helper] # project-owned — pushed, never bound
trigger: [review-on-lock] # project-owned — pushed, never bound
linked: # org-scoped — pushed, then BOUND to invoice-ops
activity-plan: [invoice-posting]
task-template: [invoice-review]
task-status: [todo, in-review, approved]
data-definition: [invoices]
document-store: [invoice-inbox]
service-bridge: [erp]
Two silent traps here:
task-templateandtask-statusare carved out of that folding. Listed as a plain project key instead of underlinked:they are pushed and never bound — the resource exists, the project cannot use it, nothing errors. Always give them their ownlinked:entry.- Deleting a slug from
linked:does not unbind it. The CLI only ever creates bindings; there is no unbind command. Remove the binding through the API or the project's Resources panel.
In a project template — typed arrays, not a resources: block
Project templates have no resources: list. Each resource kind is its own array, and only some
of them create bindings when a project is materialized from the template:
slug: invoice-ops-template
orgSlug: acme-corp
type: project-template
name: "Invoice Operations"
activityPlans: # binds activity-plan (ref: REQUIRED)
- ref: "activity-plan://${orgSlug}/invoice-posting"
taskTemplates: # binds task-template
- ref: "task-template://${orgSlug}/invoice-review"
taskStatuses: # creates org rows, then binds task-status
- { label: "To Do", slug: todo, statusType: OPEN }
taxonomies: # binds data-definition
- ref: "${orgSlug}/invoices"
dataForms: # binds data-form
- ref: "${orgSlug}/invoice-review-form"
stores: # binds document-store / data-store
- ref: "${orgSlug}/invoice-inbox"
${orgSlug}resolves to the new project's organization at materialization time.ref:acceptsscheme://orgSlug/slug,orgSlug/slug, or a bareslug(that org). The scheme is stripped and never checked — aref:undertaxonomies:is looked up as a taxonomy.assistants:,triggers:andknowledgeSets:create project-owned rows — no binding.- An unresolvable
ref:is logged and skipped. The project is created "successfully" with the binding missing, and the failure only surfaces later as a start-time or empty-picker problem. - The template is applied once, when a project is created from it. Editing the template later binds nothing to projects that already exist.
Valid resourceType values
Exactly these 21 strings are accepted; anything else is rejected at create with
invalid resourceType. All kebab-case.
activity-plan task-template task-status trigger
intake document-store data-store data-definition
data-form module prompt prompt-template
service-bridge knowledge-set knowledge-item-type
knowledge-feature-type project-template assistant action
workflow label
Two type traps that fail in opposite directions:
| You write | What happens |
|---|---|
resourceType: taxonomy |
Rejected outright — data definitions bind as data-definition. (A taxonomy:// URI is fine; it is only the type string that must be data-definition.) |
resourceType: prompt-template |
Accepted, and no project-team access follows — prompt visibility is keyed on prompt. A manifest linked: prompt-template: entry also writes prompt-template, so a prompt binding has to come from the API. |
The resourceUri scheme and the resourceType are resolved independently and never cross-checked.
resourceType: prompt with resourceUri: "activity-plan://acme-corp/invoice-posting" creates a
binding that points at a plan and is labelled a prompt. Nothing complains; nothing works.
What a binding actually controls
Enforced — a missing binding is the bug:
| Behaviour | Failure without the binding |
|---|---|
| Starting an activity from a plan | 400 — activity-plan "…" is not bound to project …; create a project-resource binding first |
| A trigger firing a plan | The same start call fails; the fire is recorded with reason binding_missing |
| An intake auto-starting a plan | The plan and the intake's document store must be bound to exactly one common project. Zero common projects → 400 "not bound to the same project"; two or more → 400 ambiguous |
| An intake creating a task from a task-template | The owning project is read off that template's binding (oldest first); with no binding the task is silently not created |
| Which document families an activity, step or script sees | Only families in document stores bound to the project — others are filtered out at start and read as "not found in project" |
A script calling serviceBridge.*, and agent-initiated bridge calls |
Bridge not found / "not linked to this agent's project" |
| Setting a task group's status slug | 422 — "is not bound to project …; bind the task-status via project-resources first" |
| Listing task-templates or task-statuses filtered by project | The project filter resolves through the binding table, so an unbound row is missing from every project-scoped list and picker — empty, not an error |
| Access for a user whose role comes from a team-project assignment | The org resource is invisible in lists and denied on read |
| Deleting the org resource itself | 409 — "resource is linked to N project(s); unlink it before deleting". Unbind everywhere first |
Not enforced — binding these is good hygiene for visibility, but a missing binding will not be the cause of your failure:
- A
CREATE_TASKstep'staskTemplateRef— resolved against the organization only. - That step's
taskStatusSlug— resolved against the organization only. - An LLM step's
promptTemplateRef— resolved against the organization only. - A
BRIDGE_CALLstep'sserviceBridgeRef— only the scriptserviceBridge.*API and the agent proxy check a binding.
Declared but inert
Fields you will meet on a binding row that nothing in the platform reads:
bindSource— provenance only; no behaviour branches on it. TheX-Bind-Sourceheader that would setUI/KDX_SYNC/PROJECT_TEMPLATEis not sent by any shipped tool, so API-made bindings readAPI;MIGRATIONandLEGACYappear only on rows written by schema upgrades;INTAKE_AUTOis declared and never written. Treat any value as "someone bound this".createdById— stamped from the caller by/bind, never read.resourceType: assistantandresourceType: trigger— accepted, but assistants and triggers already carry their own project id, and no code path consults a binding row for them.resourceType: actionandresourceType: workflow— accepted, no consumer, and no URI scheme, so they can only be bound by rawresourceId.project_resource.bound/project_resource.unboundplatform events — defined but never emitted. Do not expect a bind or unbind to show up in an event feed.
Common mistakes
| Mistake | Fix |
|---|---|
resourceType: activityPlan (camelCase) |
kebab-case: activity-plan |
resourceUri: "invoice-posting" (bare slug) |
Full URI activity-plan://acme-corp/invoice-posting, or pass resourceId |
Expecting a resource in a project picker after kdx sync push |
Check the manifest's linked: block, not the resource file — and mind the task-template / task-status carve-out |
Removing a linked: entry to unbind |
Delete the binding row; the CLI never unbinds |
| Assuming a local alias or version pin exists | Neither exists — resources keep their org slug and are unversioned |
Cross-references
activity-plan(its start is binding-gated),task-templateandtask-status(the two types authors most often forget to bind),trigger(project-owned, never bound — but its plan must be)project-template— where most bindings are authored;kdx-cli— manifest layout and push order