diff --git a/.github/workflows/schemas-drift.yml b/.github/workflows/schemas-drift.yml
index 57b9586..60fe712 100644
--- a/.github/workflows/schemas-drift.yml
+++ b/.github/workflows/schemas-drift.yml
@@ -15,14 +15,14 @@ jobs:
steps:
- uses: actions/checkout@v4
# TODO: drop the "not live yet" skip-with-warning branch below and make
- # this job strict once prod serves GET /schemas/{project,agent}.json.
+ # this job strict once prod serves GET /schemas/{project,agent,bindings}.json.
- name: Check vendored schemas against the platform API
env:
MAJOR_SCHEMAS_BASE_URL: https://api.major.tech
run: |
set -e
status=0
- for pair in "project:projects/schemas/project.schema.json" "agent:projects/schemas/agent.schema.json"; do
+ for pair in "project:projects/schemas/project.schema.json" "agent:projects/schemas/agent.schema.json" "bindings:projects/schemas/bindings.schema.json"; do
name="${pair%%:*}"
vendored="${pair##*:}"
url="$MAJOR_SCHEMAS_BASE_URL/schemas/$name.json"
diff --git a/Makefile b/Makefile
index 35d06de..5f4ab48 100644
--- a/Makefile
+++ b/Makefile
@@ -50,7 +50,7 @@ release:
# Vendor-sync projects/schemas/*.schema.json from the platform API. mono-builder
# is the source of truth: it generates these schemas from zod and serves them at
-# GET /schemas/project.json and GET /schemas/agent.json. Refresh the
+# GET /schemas/{project,agent,bindings}.json. Refresh the
# vendored copy and its SCHEMAS.sha256 manifest after an upstream schema change.
# Override the source with MAJOR_SCHEMAS_BASE_URL, e.g. against a local dev API:
# MAJOR_SCHEMAS_BASE_URL=http://localhost:3301 make sync-schemas
@@ -59,9 +59,10 @@ sync-schemas:
echo "Syncing schemas from $$base..."; \
curl -fsSL "$$base/schemas/project.json" -o projects/schemas/project.schema.json; \
curl -fsSL "$$base/schemas/agent.json" -o projects/schemas/agent.schema.json; \
+ curl -fsSL "$$base/schemas/bindings.json" -o projects/schemas/bindings.schema.json; \
{ \
echo "source: $$base"; \
echo "fetched: $$(date -u +%Y-%m-%dT%H:%M:%SZ)"; \
- shasum -a 256 projects/schemas/project.schema.json projects/schemas/agent.schema.json; \
+ shasum -a 256 projects/schemas/project.schema.json projects/schemas/agent.schema.json projects/schemas/bindings.schema.json; \
} > projects/schemas/SCHEMAS.sha256; \
echo "Done. Vendored schemas + projects/schemas/SCHEMAS.sha256 written."
diff --git a/projects/bindings.go b/projects/bindings.go
new file mode 100644
index 0000000..26155cd
--- /dev/null
+++ b/projects/bindings.go
@@ -0,0 +1,244 @@
+package projects
+
+// bindings.json parsing plus the compile-time reference validation that hangs
+// off it. bindings.json is the human-authored slot -> platform-id manifest at
+// the project root, next to project.json: agents reference platform resources,
+// applications, and skills by author-chosen slot names, and this file is where
+// those slots get their concrete ids. Slots are purely an authoring
+// convenience: compile validates that every reference resolves and then
+// substitutes the bound id inline on the referencing agent, so no slot name
+// and no copy of the manifest ever reaches the compiled config.
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+)
+
+// BindingsFileName is the project-root manifest compile reads slots from.
+const BindingsFileName = "bindings.json"
+
+// rawBindings mirrors bindings.json. The schema (schemas/bindings.schema.json)
+// enforces slot-name charset, uuid format, and non-empty resource types; this
+// only has to carry the values through.
+type rawBindings struct {
+ Slots BindingSlots `json:"slots"`
+}
+
+// loadBindings reads and validates
/bindings.json. A missing file is not
+// an error (the manifest is optional): it returns nil bindings and no issues,
+// and reference validation then tells the author to add the file only if some
+// agent actually references a slot.
+func loadBindings(dir string) (*BindingSlots, []Issue) {
+ path := filepath.Join(dir, BindingsFileName)
+
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+
+ return nil, []Issue{{File: BindingsFileName, Message: "cannot read " + BindingsFileName + ": " + err.Error()}}
+ }
+
+ if issues := validateAgainst(BindingsSchema(), raw, BindingsFileName); len(issues) > 0 {
+ return nil, issues
+ }
+
+ var parsed rawBindings
+ if err := json.Unmarshal(raw, &parsed); err != nil {
+ return nil, []Issue{{File: BindingsFileName, Message: "invalid JSON: " + err.Error()}}
+ }
+
+ return &parsed.Slots, nil
+}
+
+// The three resolve* helpers below turn authored slot references into the
+// compiled id-bearing form. They index bindings directly because Load has
+// already rejected every unresolvable reference (validateAgentReferences), and
+// Compile bails before reaching here if Load reported any issue - so by
+// construction every slot is present.
+
+func resolveConnectors(refs []AgentConnectorRef, bindings *BindingSlots) []CompiledConnector {
+ if len(refs) == 0 {
+ return nil
+ }
+
+ out := make([]CompiledConnector, 0, len(refs))
+ for _, ref := range refs {
+ out = append(out, CompiledConnector{
+ ResourceID: bindings.Resources[ref.Slot].ID,
+ Permissions: ref.Permissions,
+ })
+ }
+
+ return out
+}
+
+func resolveApps(refs []AgentAppRef, bindings *BindingSlots) []CompiledApp {
+ if len(refs) == 0 {
+ return nil
+ }
+
+ out := make([]CompiledApp, 0, len(refs))
+ for _, ref := range refs {
+ out = append(out, CompiledApp{
+ ApplicationID: bindings.Applications[ref.Slot].ID,
+ Permissions: ref.Permissions,
+ })
+ }
+
+ return out
+}
+
+// resolveSkillRefs keeps a project-local slug as-is (the skill has no platform
+// id until this same deploy publishes it) and resolves a slot to its bound id.
+func resolveSkillRefs(refs []AgentSkillRef, bindings *BindingSlots) []CompiledSkillRef {
+ if len(refs) == 0 {
+ return nil
+ }
+
+ out := make([]CompiledSkillRef, 0, len(refs))
+ for _, ref := range refs {
+ if ref.Slot != "" {
+ out = append(out, CompiledSkillRef{SkillID: bindings.Skills[ref.Slot].ID})
+ continue
+ }
+
+ out = append(out, CompiledSkillRef{Slug: ref.Slug})
+ }
+
+ return out
+}
+
+// validateAgentReferences checks that every slot an agent references exists in
+// the project's bindings, and that every bare skill string names a skill
+// directory compiled in this run. bindings is nil when the project has no
+// bindings.json, in which case any slot reference is reported as a missing
+// manifest rather than a missing slot. srcDir only names the directory a
+// missing project-local skill would have to live in.
+func validateAgentReferences(agent AgentDefinition, bindings *BindingSlots, skillSlugs map[string]bool, srcDir string) []Issue {
+ agentFile := filepath.Join(agent.Dir, "agent.json")
+
+ var issues []Issue
+
+ for i, connector := range agent.Connectors {
+ path := fmt.Sprintf("/connectors/%d/slot", i)
+
+ if issue := checkSlot(agentFile, path, connector.Slot, "resources", bindings, resourceSlotNames(bindings)); issue != nil {
+ issues = append(issues, *issue)
+ }
+ }
+
+ for i, app := range agent.Apps {
+ path := fmt.Sprintf("/apps/%d/slot", i)
+
+ if issue := checkSlot(agentFile, path, app.Slot, "applications", bindings, idSlotNames(applicationBindings(bindings))); issue != nil {
+ issues = append(issues, *issue)
+ }
+ }
+
+ for i, skill := range agent.Skills {
+ if skill.Slot != "" {
+ path := fmt.Sprintf("/skills/%d/slot", i)
+
+ if issue := checkSlot(agentFile, path, skill.Slot, "skills", bindings, idSlotNames(skillBindings(bindings))); issue != nil {
+ issues = append(issues, *issue)
+ }
+
+ continue
+ }
+
+ if !skillSlugs[skill.Slug] {
+ issues = append(issues, Issue{
+ File: agentFile,
+ Path: fmt.Sprintf("/skills/%d", i),
+ Message: fmt.Sprintf("skill %q is not a skill in this project; add %s/SKILL.md, or reference a platform skill with {\"slot\": ...}", skill.Slug, filepath.ToSlash(filepath.Join(srcDir, "skills", skill.Slug))),
+ })
+ }
+ }
+
+ return issues
+}
+
+// checkSlot reports the one right issue for a slot reference: the manifest is
+// missing entirely, or the slot is absent from the given kind.
+func checkSlot(agentFile, path, slot, kind string, bindings *BindingSlots, known []string) *Issue {
+ if bindings == nil {
+ return &Issue{
+ File: agentFile,
+ Path: path,
+ Message: fmt.Sprintf("references binding slot %q but this project has no %s; add %s at the project root with slots.%s.%s", slot, BindingsFileName, BindingsFileName, kind, slot),
+ }
+ }
+
+ for _, name := range known {
+ if name == slot {
+ return nil
+ }
+ }
+
+ return &Issue{
+ File: agentFile,
+ Path: path,
+ Message: fmt.Sprintf("unknown binding slot %q: %s declares no slots.%s.%s (declared: %s)", slot, BindingsFileName, kind, slot, describeSlots(known)),
+ }
+}
+
+func describeSlots(names []string) string {
+ if len(names) == 0 {
+ return "none"
+ }
+
+ out := ""
+ for i, name := range names {
+ if i > 0 {
+ out += ", "
+ }
+ out += fmt.Sprintf("%q", name)
+ }
+
+ return out
+}
+
+func resourceSlotNames(bindings *BindingSlots) []string {
+ if bindings == nil {
+ return nil
+ }
+
+ names := make([]string, 0, len(bindings.Resources))
+ for name := range bindings.Resources {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+
+ return names
+}
+
+func applicationBindings(bindings *BindingSlots) map[string]IDBinding {
+ if bindings == nil {
+ return nil
+ }
+
+ return bindings.Applications
+}
+
+func skillBindings(bindings *BindingSlots) map[string]IDBinding {
+ if bindings == nil {
+ return nil
+ }
+
+ return bindings.Skills
+}
+
+func idSlotNames(entries map[string]IDBinding) []string {
+ names := make([]string, 0, len(entries))
+ for name := range entries {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+
+ return names
+}
diff --git a/projects/bindings_test.go b/projects/bindings_test.go
new file mode 100644
index 0000000..6260a47
--- /dev/null
+++ b/projects/bindings_test.go
@@ -0,0 +1,436 @@
+package projects
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+const (
+ testResourceID = "11111111-1111-4111-8111-111111111111"
+ testApplicationID = "22222222-2222-4222-8222-222222222222"
+ testSkillID = "33333333-3333-4333-8333-333333333333"
+)
+
+// writeBindings writes a bindings.json body into the project root.
+func writeBindings(t *testing.T, dir, body string) {
+ t.Helper()
+
+ if err := os.WriteFile(filepath.Join(dir, BindingsFileName), []byte(body), 0644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// writeAgent writes an agent.json under /src/agents//.
+func writeAgent(t *testing.T, dir, slug, body string) {
+ t.Helper()
+
+ agentDir := filepath.Join(dir, "src", "agents", slug)
+ if err := os.MkdirAll(agentDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(agentDir, "agent.json"), []byte(body), 0644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// writeSkill writes a minimal valid skill under /src/skills//.
+func writeSkill(t *testing.T, dir, slug string) {
+ t.Helper()
+
+ skillDir := filepath.Join(dir, "src", "skills", slug)
+ if err := os.MkdirAll(skillDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ content := "---\nname: " + slug + "\ndescription: A test skill.\n---\nBody.\n"
+ if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+const fullBindings = `{
+ "$schema": "https://schemas.major.tech/bindings.json",
+ "slots": {
+ "resources": { "prod-db": { "type": "postgresql", "id": "` + testResourceID + `" } },
+ "applications": { "crm": { "id": "` + testApplicationID + `" } },
+ "skills": { "reporting": { "id": "` + testSkillID + `" } }
+ }
+}`
+
+const fullAgent = `{
+ "slug": "triage",
+ "name": "Triage",
+ "systemPrompt": "You triage.",
+ "connectors": [{ "slot": "prod-db", "permissions": [{ "tool": "postgresql_psql", "decision": "always_allow" }] }],
+ "apps": [{ "slot": "crm", "permissions": [{ "method": "POST", "path": "/api/orders", "decision": "ask" }] }],
+ "skills": ["pdf-tools", { "slot": "reporting" }]
+}`
+
+// TestCompileFullAgentWithBindings is the happy path: all three facets on one
+// agent, every reference resolving through bindings.json or a project-local
+// skill directory.
+func TestCompileFullAgentWithBindings(t *testing.T) {
+ dir := t.TempDir()
+ writeFixtureProject(t, dir)
+ writeBindings(t, dir, fullBindings)
+ writeSkill(t, dir, "pdf-tools")
+ writeAgent(t, dir, "triage", fullAgent)
+ initGitRepo(t, dir)
+
+ res, issues := Compile(dir)
+ if len(issues) != 0 {
+ t.Fatalf("expected no issues, got: %+v", issues)
+ }
+
+ agent := res.Config.Agents[0]
+ if len(agent.Connectors) != 1 || agent.Connectors[0].ResourceID != testResourceID {
+ t.Fatalf("connectors = %+v", agent.Connectors)
+ }
+ if p := agent.Connectors[0].Permissions; len(p) != 1 || p[0].Tool != "postgresql_psql" || p[0].Decision != "always_allow" {
+ t.Fatalf("connector permissions = %+v", p)
+ }
+ if len(agent.Apps) != 1 || agent.Apps[0].ApplicationID != testApplicationID {
+ t.Fatalf("apps = %+v", agent.Apps)
+ }
+ if p := agent.Apps[0].Permissions; len(p) != 1 || p[0].Method != "POST" || p[0].Path != "/api/orders" || p[0].Decision != "ask" {
+ t.Fatalf("app permissions = %+v", p)
+ }
+
+ // A bare string stays a slug (the local skill has no id until this deploy
+ // publishes it); a slot resolves to the bound platform skill id.
+ want := []CompiledSkillRef{{Slug: "pdf-tools"}, {SkillID: testSkillID}}
+ if len(agent.Skills) != len(want) {
+ t.Fatalf("skills = %+v", agent.Skills)
+ }
+ for i := range want {
+ if agent.Skills[i] != want[i] {
+ t.Fatalf("skills[%d] = %+v, want %+v", i, agent.Skills[i], want[i])
+ }
+ }
+
+ var raw map[string]any
+ if err := json.Unmarshal(res.ConfigJSON, &raw); err != nil {
+ t.Fatalf("compiled JSON does not parse: %v", err)
+ }
+ if _, ok := raw["bindings"]; ok {
+ t.Fatalf("the slot manifest must not reach the compiled config: %s", res.ConfigJSON)
+ }
+ if !strings.Contains(string(res.ConfigJSON), `"skills":[{"slug":"pdf-tools"},{"skillId":"`+testSkillID+`"}]`) {
+ t.Fatalf("skills not resolved as expected: %s", res.ConfigJSON)
+ }
+
+ // No slot name survives compilation anywhere in the output.
+ for _, slot := range []string{"prod-db", "crm", "reporting"} {
+ if strings.Contains(string(res.ConfigJSON), slot) {
+ t.Fatalf("slot %q leaked into the compiled config: %s", slot, res.ConfigJSON)
+ }
+ }
+}
+
+// TestCompileNeverEmitsBindingsKey checks that a project WITH bindings.json
+// still emits no top-level bindings key: the manifest is compile-time input
+// only, with no downstream consumer.
+func TestCompileNeverEmitsBindingsKey(t *testing.T) {
+ dir := t.TempDir()
+ writeFixtureProject(t, dir)
+ writeBindings(t, dir, fullBindings)
+
+ res, issues := Compile(dir)
+ if len(issues) != 0 {
+ t.Fatalf("expected no issues, got: %+v", issues)
+ }
+
+ if strings.Contains(string(res.ConfigJSON), `"bindings"`) {
+ t.Fatalf("expected no bindings key: %s", res.ConfigJSON)
+ }
+ for _, id := range []string{testResourceID, testApplicationID, testSkillID} {
+ if strings.Contains(string(res.ConfigJSON), id) {
+ t.Fatalf("unreferenced binding id %q leaked into the compiled config: %s", id, res.ConfigJSON)
+ }
+ }
+}
+
+// TestCompileWithoutBindingsFileOmitsBindingsKey pins backward compatibility:
+// a project that predates bindings.json compiles to exactly the bytes it did
+// before, with no bindings key and no facet keys.
+func TestCompileWithoutBindingsFileOmitsBindingsKey(t *testing.T) {
+ res, issues := Compile("testdata/valid")
+ if len(issues) != 0 {
+ t.Fatalf("expected no issues, got: %+v", issues)
+ }
+
+ for _, key := range []string{`"bindings"`, `"connectors"`, `"apps"`, `"skills"`} {
+ if strings.Contains(string(res.ConfigJSON), key) {
+ t.Fatalf("expected no %s key for an old project, got: %s", key, res.ConfigJSON)
+ }
+ }
+}
+
+func TestCompileUnknownConnectorSlotRejected(t *testing.T) {
+ dir := t.TempDir()
+ writeFixtureProject(t, dir)
+ writeBindings(t, dir, fullBindings)
+ writeAgent(t, dir, "triage", `{"slug":"triage","name":"T","systemPrompt":"x","connectors":[{"slot":"staging-db"}]}`)
+
+ _, issues := Compile(dir)
+ if !findIssue(issues, `unknown binding slot "staging-db"`) || !findIssue(issues, "slots.resources") {
+ t.Fatalf("expected an unknown resource-slot issue, got: %+v", issues)
+ }
+}
+
+func TestCompileUnknownAppSlotRejected(t *testing.T) {
+ dir := t.TempDir()
+ writeFixtureProject(t, dir)
+ writeBindings(t, dir, fullBindings)
+ writeAgent(t, dir, "triage", `{"slug":"triage","name":"T","systemPrompt":"x","apps":[{"slot":"billing"}]}`)
+
+ _, issues := Compile(dir)
+ if !findIssue(issues, `unknown binding slot "billing"`) || !findIssue(issues, "slots.applications") {
+ t.Fatalf("expected an unknown application-slot issue, got: %+v", issues)
+ }
+}
+
+func TestCompileUnknownSkillSlotRejected(t *testing.T) {
+ dir := t.TempDir()
+ writeFixtureProject(t, dir)
+ writeBindings(t, dir, fullBindings)
+ writeAgent(t, dir, "triage", `{"slug":"triage","name":"T","systemPrompt":"x","skills":[{"slot":"forecasting"}]}`)
+
+ _, issues := Compile(dir)
+ if !findIssue(issues, `unknown binding slot "forecasting"`) || !findIssue(issues, "slots.skills") {
+ t.Fatalf("expected an unknown skill-slot issue, got: %+v", issues)
+ }
+}
+
+func TestCompileUnknownProjectSkillRejected(t *testing.T) {
+ dir := t.TempDir()
+ writeFixtureProject(t, dir)
+ writeSkill(t, dir, "pdf-tools")
+ writeAgent(t, dir, "triage", `{"slug":"triage","name":"T","systemPrompt":"x","skills":["ocr-tools"]}`)
+ initGitRepo(t, dir)
+
+ _, issues := Compile(dir)
+ if !findIssue(issues, `skill "ocr-tools" is not a skill in this project`) {
+ t.Fatalf("expected an unresolved project-skill issue, got: %+v", issues)
+ }
+}
+
+// TestCompileSlotReferenceWithoutBindingsFile checks the no-manifest case gets
+// its own message telling the author to add bindings.json, not a confusing
+// "unknown slot" against an empty manifest.
+func TestCompileSlotReferenceWithoutBindingsFile(t *testing.T) {
+ dir := t.TempDir()
+ writeFixtureProject(t, dir)
+ writeAgent(t, dir, "triage", `{"slug":"triage","name":"T","systemPrompt":"x","connectors":[{"slot":"prod-db"}]}`)
+
+ _, issues := Compile(dir)
+ if !findIssue(issues, "this project has no bindings.json") {
+ t.Fatalf("expected a missing-bindings.json issue, got: %+v", issues)
+ }
+}
+
+func TestCompileInvalidBindingsRejected(t *testing.T) {
+ tests := []struct {
+ name string
+ body string
+ want string
+ }{
+ {"malformed JSON", `{"slots":`, "invalid JSON"},
+ {"unknown top-level key", `{"slots":{},"extra":1}`, "extra"},
+ {"unknown slot kind", `{"slots":{"widgets":{}}}`, "widgets"},
+ {"non-uuid id", `{"slots":{"applications":{"crm":{"id":"not-a-uuid"}}}}`, "pattern"},
+ {"empty resource type", `{"slots":{"resources":{"db":{"type":"","id":"` + testResourceID + `"}}}}`, "minLength"},
+ {"bad slot charset", `{"slots":{"applications":{"Bad_Slot":{"id":"` + testApplicationID + `"}}}}`, "pattern"},
+ {"unknown entry key", `{"slots":{"applications":{"crm":{"id":"` + testApplicationID + `","name":"x"}}}}`, "name"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ dir := t.TempDir()
+ writeFixtureProject(t, dir)
+ writeBindings(t, dir, tt.body)
+
+ _, issues := Compile(dir)
+ if len(issues) == 0 {
+ t.Fatal("expected an issue")
+ }
+ if issues[0].File != BindingsFileName {
+ t.Fatalf("issue not attributed to %s: %+v", BindingsFileName, issues)
+ }
+ if !findIssue(issues, tt.want) {
+ t.Fatalf("expected an issue mentioning %q, got: %+v", tt.want, issues)
+ }
+ })
+ }
+}
+
+func TestCompileDuplicateFacetEntriesRejected(t *testing.T) {
+ tests := []struct {
+ name string
+ agent string
+ want string
+ }{
+ {
+ name: "duplicate connector slot",
+ agent: `{"slug":"a","name":"A","systemPrompt":"x","connectors":[{"slot":"prod-db"},{"slot":"prod-db"}]}`,
+ want: `duplicate connector slot "prod-db"`,
+ },
+ {
+ name: "duplicate app slot",
+ agent: `{"slug":"a","name":"A","systemPrompt":"x","apps":[{"slot":"crm"},{"slot":"crm"}]}`,
+ want: `duplicate app slot "crm"`,
+ },
+ {
+ name: "duplicate connector tool",
+ agent: `{"slug":"a","name":"A","systemPrompt":"x","connectors":[{"slot":"prod-db","permissions":[{"tool":"t","decision":"ask"},{"tool":"t","decision":"always_deny"}]}]}`,
+ want: `duplicate permission for tool "t"`,
+ },
+ {
+ name: "duplicate app endpoint",
+ agent: `{"slug":"a","name":"A","systemPrompt":"x","apps":[{"slot":"crm","permissions":[{"method":"GET","path":"/x","decision":"ask"},{"method":"GET","path":"/x","decision":"always_allow"}]}]}`,
+ want: "duplicate permission for GET /x",
+ },
+ {
+ name: "duplicate skill slot",
+ agent: `{"slug":"a","name":"A","systemPrompt":"x","skills":[{"slot":"reporting"},{"slot":"reporting"}]}`,
+ want: `duplicate reference to skill slot "reporting"`,
+ },
+ {
+ name: "duplicate project skill",
+ agent: `{"slug":"a","name":"A","systemPrompt":"x","skills":["pdf-tools","pdf-tools"]}`,
+ want: `duplicate reference to project skill "pdf-tools"`,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ dir := t.TempDir()
+ writeFixtureProject(t, dir)
+ writeBindings(t, dir, fullBindings)
+ writeSkill(t, dir, "pdf-tools")
+ writeAgent(t, dir, "a", tt.agent)
+ initGitRepo(t, dir)
+
+ _, issues := Compile(dir)
+ if !findIssue(issues, tt.want) {
+ t.Fatalf("expected an issue mentioning %q, got: %+v", tt.want, issues)
+ }
+ })
+ }
+}
+
+func TestCompileInvalidAgentFacetShapeRejected(t *testing.T) {
+ tests := []struct {
+ name string
+ agent string
+ want string
+ }{
+ {
+ name: "unknown decision",
+ agent: `{"slug":"a","name":"A","systemPrompt":"x","connectors":[{"slot":"prod-db","permissions":[{"tool":"t","decision":"maybe"}]}]}`,
+ want: "must be one of",
+ },
+ {
+ name: "connector without slot",
+ agent: `{"slug":"a","name":"A","systemPrompt":"x","connectors":[{"permissions":[]}]}`,
+ want: "slot",
+ },
+ {
+ name: "app permission without path",
+ agent: `{"slug":"a","name":"A","systemPrompt":"x","apps":[{"slot":"crm","permissions":[{"method":"GET","decision":"ask"}]}]}`,
+ want: "path",
+ },
+ {
+ name: "unknown key on a connector",
+ agent: `{"slug":"a","name":"A","systemPrompt":"x","connectors":[{"slot":"prod-db","upstream":true}]}`,
+ want: "upstream",
+ },
+ {
+ name: "skill ref that is neither a string nor a slot object",
+ agent: `{"slug":"a","name":"A","systemPrompt":"x","skills":[{"slug":"pdf-tools"}]}`,
+ want: "slug",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ dir := t.TempDir()
+ writeFixtureProject(t, dir)
+ writeBindings(t, dir, fullBindings)
+ writeAgent(t, dir, "a", tt.agent)
+
+ _, issues := Compile(dir)
+ if !findIssue(issues, tt.want) {
+ t.Fatalf("expected an issue mentioning %q, got: %+v", tt.want, issues)
+ }
+ })
+ }
+}
+
+// TestCompileSkillSlotMayShareNameWithProjectSkill pins that a skills slot
+// named after a project-local skill is NOT a conflict: compile resolves the
+// slot to {"skillId"} and the local slug to {"slug"}, two structurally
+// distinct shapes, so there is no merged namespace for them to collide in.
+func TestCompileSkillSlotMayShareNameWithProjectSkill(t *testing.T) {
+ dir := t.TempDir()
+ writeFixtureProject(t, dir)
+ writeBindings(t, dir, `{"slots":{"skills":{"pdf-tools":{"id":"`+testSkillID+`"}}}}`)
+ writeSkill(t, dir, "pdf-tools")
+ writeAgent(t, dir, "a", `{"slug":"a","name":"A","systemPrompt":"x","skills":["pdf-tools",{"slot":"pdf-tools"}]}`)
+ initGitRepo(t, dir)
+
+ res, issues := Compile(dir)
+ if len(issues) != 0 {
+ t.Fatalf("expected no issues, got: %+v", issues)
+ }
+
+ want := []CompiledSkillRef{{Slug: "pdf-tools"}, {SkillID: testSkillID}}
+ got := res.Config.Agents[0].Skills
+ if len(got) != len(want) {
+ t.Fatalf("skills = %+v", got)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("skills[%d] = %+v, want %+v", i, got[i], want[i])
+ }
+ }
+}
+
+// TestLoadStillReservedFields pins that unreserving connectors/apps/skills did
+// not unreserve the rest.
+func TestLoadStillReservedFields(t *testing.T) {
+ for _, field := range []string{"schedules", "toolPermissions", "tools", "hooks"} {
+ t.Run(field, func(t *testing.T) {
+ dir := t.TempDir()
+ writeFixtureProject(t, dir)
+ writeAgent(t, dir, "a", `{"slug":"a","name":"A","systemPrompt":"x","`+field+`":[]}`)
+
+ _, issues := Load(dir)
+ if !findIssue(issues, "reserved for a future version") || !findIssue(issues, field) {
+ t.Fatalf("expected %q to stay reserved, got: %+v", field, issues)
+ }
+ if len(issues) != 1 {
+ t.Fatalf("expected exactly one issue for %q, got: %+v", field, issues)
+ }
+ })
+ }
+}
+
+// TestLoadFacetsNoLongerReserved is the mirror of the test above: the three
+// unreserved fields must parse instead of erroring.
+func TestLoadFacetsNoLongerReserved(t *testing.T) {
+ dir := t.TempDir()
+ writeFixtureProject(t, dir)
+ writeBindings(t, dir, fullBindings)
+ writeAgent(t, dir, "a", `{"slug":"a","name":"A","systemPrompt":"x","connectors":[{"slot":"prod-db"}],"apps":[{"slot":"crm"}],"skills":[{"slot":"reporting"}]}`)
+
+ loaded, issues := Load(dir)
+ if len(issues) != 0 {
+ t.Fatalf("expected no issues, got: %+v", issues)
+ }
+ if len(loaded.Agents[0].Connectors) != 1 || len(loaded.Agents[0].Apps) != 1 || len(loaded.Agents[0].Skills) != 1 {
+ t.Fatalf("facets not parsed: %+v", loaded.Agents[0])
+ }
+}
diff --git a/projects/compile.go b/projects/compile.go
index 998837b..c0c5263 100644
--- a/projects/compile.go
+++ b/projects/compile.go
@@ -69,6 +69,9 @@ func compile(dir, skillBundlesDir string) (*CompileResult, []Issue) {
Model: a.Model,
SystemPrompt: prompt,
Env: a.Env,
+ Connectors: resolveConnectors(a.Connectors, loaded.Bindings),
+ Apps: resolveApps(a.Apps, loaded.Bindings),
+ Skills: resolveSkillRefs(a.Skills, loaded.Bindings),
})
}
diff --git a/projects/load.go b/projects/load.go
index e86eb00..e9efbff 100644
--- a/projects/load.go
+++ b/projects/load.go
@@ -20,9 +20,10 @@ var schemaMessagePrinter = message.NewPrinter(language.English)
// reservedAgentFields are agent.json keys claimed by future versions. They are
// rejected with a dedicated message so v1 can introduce them without silent
-// behavior changes on old CLIs.
+// behavior changes on old CLIs. "connectors", "apps", and "skills" were
+// reserved in v0 and are now supported (MAJ-290).
var reservedAgentFields = []string{
- "schedules", "connectors", "apps", "toolPermissions", "tools", "hooks", "skills",
+ "schedules", "toolPermissions", "tools", "hooks",
}
// rawSystemPrompt accepts either an inline string or {"file": "./x.md"}.
@@ -50,13 +51,120 @@ func (s *rawSystemPrompt) UnmarshalJSON(b []byte) error {
return nil
}
+// rawSkillRef accepts either a bare string (a project-local skill slug) or
+// {"slot": "..."} (a platform skill via bindings.json). The two syntaxes are
+// disjoint, so exactly one field is ever set.
+type rawSkillRef AgentSkillRef
+
+func (r *rawSkillRef) UnmarshalJSON(b []byte) error {
+ var slug string
+ if err := json.Unmarshal(b, &slug); err == nil {
+ r.Slug = slug
+ return nil
+ }
+
+ var obj struct {
+ Slot string `json:"slot"`
+ }
+
+ if err := json.Unmarshal(b, &obj); err != nil {
+ return err
+ }
+
+ r.Slot = obj.Slot
+ return nil
+}
+
type rawAgent struct {
- Slug string `json:"slug"`
- Name string `json:"name"`
- Description string `json:"description"`
- Model string `json:"model"`
- SystemPrompt rawSystemPrompt `json:"systemPrompt"`
- Env map[string]*string `json:"env"`
+ Slug string `json:"slug"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Model string `json:"model"`
+ SystemPrompt rawSystemPrompt `json:"systemPrompt"`
+ Env map[string]*string `json:"env"`
+ Connectors []AgentConnectorRef `json:"connectors"`
+ Apps []AgentAppRef `json:"apps"`
+ Skills []rawSkillRef `json:"skills"`
+}
+
+// checkAgentFacetDuplicates reports duplicate slots, duplicate per-connector
+// tools, duplicate per-app {method,path} pairs, and duplicate skill
+// references within a single agent.json. Duplicates are always an authoring
+// mistake: the later entry would silently win at deploy.
+func checkAgentFacetDuplicates(ra rawAgent, agentFile string) []Issue {
+ var issues []Issue
+
+ seenConnectors := map[string]bool{}
+ for i, connector := range ra.Connectors {
+ if seenConnectors[connector.Slot] {
+ issues = append(issues, Issue{
+ File: agentFile,
+ Path: fmt.Sprintf("/connectors/%d/slot", i),
+ Message: fmt.Sprintf("duplicate connector slot %q", connector.Slot),
+ })
+ }
+ seenConnectors[connector.Slot] = true
+
+ seenTools := map[string]bool{}
+ for j, permission := range connector.Permissions {
+ if seenTools[permission.Tool] {
+ issues = append(issues, Issue{
+ File: agentFile,
+ Path: fmt.Sprintf("/connectors/%d/permissions/%d/tool", i, j),
+ Message: fmt.Sprintf("duplicate permission for tool %q on connector slot %q", permission.Tool, connector.Slot),
+ })
+ }
+ seenTools[permission.Tool] = true
+ }
+ }
+
+ seenApps := map[string]bool{}
+ for i, app := range ra.Apps {
+ if seenApps[app.Slot] {
+ issues = append(issues, Issue{
+ File: agentFile,
+ Path: fmt.Sprintf("/apps/%d/slot", i),
+ Message: fmt.Sprintf("duplicate app slot %q", app.Slot),
+ })
+ }
+ seenApps[app.Slot] = true
+
+ seenEndpoints := map[string]bool{}
+ for j, permission := range app.Permissions {
+ key := permission.Method + " " + permission.Path
+
+ if seenEndpoints[key] {
+ issues = append(issues, Issue{
+ File: agentFile,
+ Path: fmt.Sprintf("/apps/%d/permissions/%d", i, j),
+ Message: fmt.Sprintf("duplicate permission for %s on app slot %q", key, app.Slot),
+ })
+ }
+ seenEndpoints[key] = true
+ }
+ }
+
+ seenSkills := map[string]bool{}
+ for i, skill := range ra.Skills {
+ key := "slug:" + skill.Slug
+ label := fmt.Sprintf("project skill %q", skill.Slug)
+
+ if skill.Slot != "" {
+ key = "slot:" + skill.Slot
+ label = fmt.Sprintf("skill slot %q", skill.Slot)
+ }
+
+ if seenSkills[key] {
+ issues = append(issues, Issue{
+ File: agentFile,
+ Path: fmt.Sprintf("/skills/%d", i),
+ Message: "duplicate reference to " + label,
+ })
+ }
+ seenSkills[key] = true
+ }
+
+ return issues
}
type rawProject struct {
@@ -212,6 +320,14 @@ func Load(dir string) (*LoadedProject, []Issue) {
skills, skillIssues := discoverSkills(dir, srcDir)
issues = append(issues, skillIssues...)
+ bindings, bindingIssues := loadBindings(dir)
+ issues = append(issues, bindingIssues...)
+
+ skillSlugs := map[string]bool{}
+ for _, s := range skills {
+ skillSlugs[s.Slug] = true
+ }
+
agentsDir := filepath.Join(dir, srcDir, "agents")
entries, err := os.ReadDir(agentsDir)
if err != nil {
@@ -220,7 +336,7 @@ func Load(dir string) (*LoadedProject, []Issue) {
if len(issues) > 0 {
return nil, issues
}
- return &LoadedProject{Definition: ProjectDefinition{Name: proj.Name, SrcDir: srcDir}, Skills: skills}, nil
+ return &LoadedProject{Definition: ProjectDefinition{Name: proj.Name, SrcDir: srcDir}, Skills: skills, Bindings: bindings}, nil
}
var agents []AgentDefinition
@@ -267,20 +383,45 @@ func Load(dir string) (*LoadedProject, []Issue) {
}
seenSlugs[ra.Slug] = agentFile
+ if dupIssues := checkAgentFacetDuplicates(ra, agentFile); len(dupIssues) > 0 {
+ issues = append(issues, dupIssues...)
+ continue
+ }
+
systemPrompt := ra.SystemPrompt.Inline
if ra.SystemPrompt.File != "" {
systemPrompt = "file:" + ra.SystemPrompt.File
}
- agents = append(agents, AgentDefinition{
+ agentSkills := make([]AgentSkillRef, 0, len(ra.Skills))
+ for _, s := range ra.Skills {
+ agentSkills = append(agentSkills, AgentSkillRef(s))
+ }
+ if len(agentSkills) == 0 {
+ agentSkills = nil
+ }
+
+ agent := AgentDefinition{
Slug: ra.Slug,
Name: ra.Name,
Description: ra.Description,
Model: ra.Model,
SystemPrompt: systemPrompt,
Env: ra.Env,
+ Connectors: ra.Connectors,
+ Apps: ra.Apps,
+ Skills: agentSkills,
Dir: filepath.Join(srcDir, "agents", entry.Name()),
- })
+ }
+
+ // Reference validation needs the whole project (bindings + every
+ // project-local skill), both of which are already resolved above.
+ if refIssues := validateAgentReferences(agent, bindings, skillSlugs, srcDir); len(refIssues) > 0 {
+ issues = append(issues, refIssues...)
+ continue
+ }
+
+ agents = append(agents, agent)
}
if len(issues) > 0 {
@@ -293,5 +434,6 @@ func Load(dir string) (*LoadedProject, []Issue) {
Definition: ProjectDefinition{Name: proj.Name, SrcDir: srcDir},
Agents: agents,
Skills: skills,
+ Bindings: bindings,
}, nil
}
diff --git a/projects/load_test.go b/projects/load_test.go
index 97fb98c..9c8ae06 100644
--- a/projects/load_test.go
+++ b/projects/load_test.go
@@ -122,10 +122,10 @@ func TestLoadDuplicateSlugs(t *testing.T) {
}
}
-// TestLoadAgentSkillsFieldReservedAgain checks that agent-skill attachment is
-// gone: agent.json's "skills" field is reserved again (load.go), not parsed
-// or cross-referenced against declared skills.
-func TestLoadAgentSkillsFieldReservedAgain(t *testing.T) {
+// TestLoadAgentSkillsFieldSupported checks that agent.json's "skills" field is
+// no longer reserved (MAJ-290): a bare string names a project-local skill and
+// is cross-referenced against the project's skill directories.
+func TestLoadAgentSkillsFieldSupported(t *testing.T) {
dir := t.TempDir()
writeFixtureProject(t, dir)
@@ -139,8 +139,11 @@ func TestLoadAgentSkillsFieldReservedAgain(t *testing.T) {
}
_, issues := Load(dir)
- if !findIssue(issues, "reserved for a future version") || !findIssue(issues, "skills") {
- t.Fatalf("expected \"skills\" to be reserved again, got: %+v", issues)
+ if findIssue(issues, "reserved for a future version") {
+ t.Fatalf("\"skills\" must no longer be reserved, got: %+v", issues)
+ }
+ if !findIssue(issues, `skill "pdf-tools" is not a skill in this project`) {
+ t.Fatalf("expected an unresolved project-skill issue, got: %+v", issues)
}
}
diff --git a/projects/schemas.go b/projects/schemas.go
index e4de3ab..f341c77 100644
--- a/projects/schemas.go
+++ b/projects/schemas.go
@@ -14,7 +14,8 @@ import (
"github.com/santhosh-tekuri/jsonschema/v6"
)
-// SchemaFS embeds the project.json and agent.json JSON Schemas. mono-builder
+// SchemaFS embeds the project.json, agent.json, and bindings.json JSON
+// Schemas. mono-builder
// is the source of truth for these: it generates them from the platform's zod
// definitions and serves them at GET /schemas/project.json and
// GET /schemas/agent.json. The files under schemas/ are a vendored
@@ -27,19 +28,21 @@ import (
var SchemaFS embed.FS
var (
- schemaOnce sync.Once
- projectSchema *jsonschema.Schema
- agentSchema *jsonschema.Schema
+ schemaOnce sync.Once
+ projectSchema *jsonschema.Schema
+ agentSchema *jsonschema.Schema
+ bindingsSchema *jsonschema.Schema
)
-// ensureSchemas compiles both embedded schemas exactly once.
+// ensureSchemas compiles every embedded schema exactly once.
func ensureSchemas() {
schemaOnce.Do(func() {
c := jsonschema.NewCompiler()
for name, id := range map[string]string{
- "schemas/project.schema.json": "https://schemas.major.tech/project.json",
- "schemas/agent.schema.json": "https://schemas.major.tech/agent.json",
+ "schemas/project.schema.json": "https://schemas.major.tech/project.json",
+ "schemas/agent.schema.json": "https://schemas.major.tech/agent.json",
+ "schemas/bindings.schema.json": "https://schemas.major.tech/bindings.json",
} {
raw, err := SchemaFS.ReadFile(name)
if err != nil {
@@ -67,6 +70,11 @@ func ensureSchemas() {
if err != nil {
panic("projects: agent schema failed to compile: " + err.Error())
}
+
+ bindingsSchema, err = c.Compile("https://schemas.major.tech/bindings.json")
+ if err != nil {
+ panic("projects: bindings schema failed to compile: " + err.Error())
+ }
})
}
@@ -81,3 +89,9 @@ func AgentSchema() *jsonschema.Schema {
ensureSchemas()
return agentSchema
}
+
+// BindingsSchema returns the compiled bindings.json schema.
+func BindingsSchema() *jsonschema.Schema {
+ ensureSchemas()
+ return bindingsSchema
+}
diff --git a/projects/schemas/SCHEMAS.sha256 b/projects/schemas/SCHEMAS.sha256
index 9597e31..f4b8e37 100644
--- a/projects/schemas/SCHEMAS.sha256
+++ b/projects/schemas/SCHEMAS.sha256
@@ -1,4 +1,5 @@
-source: /Users/josegiron/orca/workspaces/mono-builder/project-skills (working tree, apps/api/src/shared/constants/schemas)
-fetched: 2026-07-25T23:08:17Z
+source: /Users/josegiron/orca/workspaces/mono-builder/project-full-agent (working tree, apps/api/src/shared/constants/schemas)
+fetched: 2026-07-26T07:13:33Z
47002e01681b41a1030b88e395897008b370c4a42b465b541e77038f8b2509d1 projects/schemas/project.schema.json
-30f8146f88d12dcec76653f167b0843f0edb05fde6db6e74926b2f154747128b projects/schemas/agent.schema.json
+92de228ebbe57c186a90c71761336b4cd69c692faf05c6f964e65b7c1b7b3a14 projects/schemas/agent.schema.json
+58a425b5d3aff5e93cf8e8409301c1629e121237e8de092f962247f860d8515b projects/schemas/bindings.schema.json
diff --git a/projects/schemas/agent.schema.json b/projects/schemas/agent.schema.json
index d937fdc..eff8b4a 100644
--- a/projects/schemas/agent.schema.json
+++ b/projects/schemas/agent.schema.json
@@ -24,6 +24,67 @@
"type": "object",
"propertyNames": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]*$" },
"additionalProperties": { "anyOf": [{ "type": "string" }, { "type": "null" }] }
+ },
+ "connectors": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "slot": { "type": "string", "minLength": 1, "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" },
+ "permissions": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "tool": { "type": "string", "minLength": 1 },
+ "decision": { "type": "string", "enum": ["always_allow", "ask", "always_deny"] }
+ },
+ "required": ["tool", "decision"],
+ "additionalProperties": false
+ }
+ }
+ },
+ "required": ["slot"],
+ "additionalProperties": false
+ }
+ },
+ "apps": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "slot": { "type": "string", "minLength": 1, "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" },
+ "permissions": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "method": { "type": "string", "minLength": 1 },
+ "path": { "type": "string", "minLength": 1 },
+ "decision": { "type": "string", "enum": ["always_allow", "ask", "always_deny"] }
+ },
+ "required": ["method", "path", "decision"],
+ "additionalProperties": false
+ }
+ }
+ },
+ "required": ["slot"],
+ "additionalProperties": false
+ }
+ },
+ "skills": {
+ "type": "array",
+ "items": {
+ "anyOf": [
+ { "type": "string", "minLength": 1, "maxLength": 63, "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" },
+ {
+ "type": "object",
+ "properties": { "slot": { "type": "string", "minLength": 1, "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" } },
+ "required": ["slot"],
+ "additionalProperties": false
+ }
+ ]
+ }
}
},
"required": ["slug", "name", "systemPrompt"],
diff --git a/projects/schemas/bindings.schema.json b/projects/schemas/bindings.schema.json
new file mode 100644
index 0000000..50f1f3a
--- /dev/null
+++ b/projects/schemas/bindings.schema.json
@@ -0,0 +1,65 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://schemas.major.tech/bindings.json",
+ "title": "Major project bindings",
+ "type": "object",
+ "properties": {
+ "$schema": { "type": "string" },
+ "slots": {
+ "type": "object",
+ "properties": {
+ "resources": {
+ "type": "object",
+ "propertyNames": { "type": "string", "minLength": 1, "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" },
+ "additionalProperties": {
+ "type": "object",
+ "properties": {
+ "type": { "type": "string", "minLength": 1 },
+ "id": {
+ "type": "string",
+ "format": "uuid",
+ "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
+ }
+ },
+ "required": ["type", "id"],
+ "additionalProperties": false
+ }
+ },
+ "applications": {
+ "type": "object",
+ "propertyNames": { "type": "string", "minLength": 1, "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" },
+ "additionalProperties": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "format": "uuid",
+ "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
+ }
+ },
+ "required": ["id"],
+ "additionalProperties": false
+ }
+ },
+ "skills": {
+ "type": "object",
+ "propertyNames": { "type": "string", "minLength": 1, "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" },
+ "additionalProperties": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "format": "uuid",
+ "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
+ }
+ },
+ "required": ["id"],
+ "additionalProperties": false
+ }
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/projects/schemas_test.go b/projects/schemas_test.go
index 1ed0e07..61332ca 100644
--- a/projects/schemas_test.go
+++ b/projects/schemas_test.go
@@ -13,6 +13,7 @@ func TestSchemasCompile(t *testing.T) {
}{
{"project", ProjectSchema},
{"agent", AgentSchema},
+ {"bindings", BindingsSchema},
}
for _, tt := range tests {
diff --git a/projects/types.go b/projects/types.go
index 72b792d..3299de5 100644
--- a/projects/types.go
+++ b/projects/types.go
@@ -53,9 +53,98 @@ type AgentDefinition struct {
Model string
SystemPrompt string
Env map[string]*string
+ Connectors []AgentConnectorRef
+ Apps []AgentAppRef
+ Skills []AgentSkillRef
Dir string
}
+// AgentToolPermission is one agent-scoped connector tool decision. Decision
+// mirrors the platform enum (always_allow | ask | always_deny); tools not
+// listed get no stored row and fall back to runtime defaults.
+type AgentToolPermission struct {
+ Tool string `json:"tool"`
+ Decision string `json:"decision"`
+}
+
+// AgentConnectorRef is one authored agent.json connectors[] entry: a resource
+// binding slot plus optional per-tool permissions. Compile resolves Slot
+// against bindings.json slots.resources into a CompiledConnector.
+type AgentConnectorRef struct {
+ Slot string `json:"slot"`
+ Permissions []AgentToolPermission `json:"permissions,omitempty"`
+}
+
+// CompiledConnector is one connector association in the compiled config, with
+// its binding slot already resolved to the bound resource id. Slots never
+// reach the platform: compile owns resolution, deploy consumes ids.
+type CompiledConnector struct {
+ ResourceID string `json:"resourceId"`
+ Permissions []AgentToolPermission `json:"permissions,omitempty"`
+}
+
+// AgentEndpointPermission is one agent-scoped app route decision.
+type AgentEndpointPermission struct {
+ Method string `json:"method"`
+ Path string `json:"path"`
+ Decision string `json:"decision"`
+}
+
+// AgentAppRef is one authored agent.json apps[] entry: an application binding
+// slot (the agent-calls-app-routes scope) plus optional per-endpoint
+// permissions.
+type AgentAppRef struct {
+ Slot string `json:"slot"`
+ Permissions []AgentEndpointPermission `json:"permissions,omitempty"`
+}
+
+// CompiledApp is one app association in the compiled config, with its binding
+// slot already resolved to the bound application id.
+type CompiledApp struct {
+ ApplicationID string `json:"applicationId"`
+ Permissions []AgentEndpointPermission `json:"permissions,omitempty"`
+}
+
+// AgentSkillRef is one authored skill attachment: exactly one of Slug (a
+// project-local skill compiled in the same run, authored as a bare string) or
+// Slot (a platform skill via bindings.json slots.skills) is set.
+type AgentSkillRef struct {
+ Slug string `json:"slug,omitempty"`
+ Slot string `json:"slot,omitempty"`
+}
+
+// CompiledSkillRef is one skill attachment in the compiled config: exactly one
+// of Slug (a project-local skill published by this same deploy, which has no
+// id until then) or SkillID (a platform skill, resolved from its binding slot
+// at compile time) is set. The two are structurally distinct, so there is no
+// shared namespace a slot name could collide in.
+type CompiledSkillRef struct {
+ Slug string `json:"slug,omitempty"`
+ SkillID string `json:"skillId,omitempty"`
+}
+
+// ResourceBinding is one bindings.json slots.resources entry. Type is the
+// connector subtype, carried for authoring clarity and parity with the shared
+// manifest shape; compile does not verify it against the platform.
+type ResourceBinding struct {
+ Type string `json:"type"`
+ ID string `json:"id"`
+}
+
+// IDBinding is one bindings.json slots.applications / slots.skills entry.
+type IDBinding struct {
+ ID string `json:"id"`
+}
+
+// BindingSlots is the normalized slot manifest from bindings.json. Empty
+// kinds are omitted so a project that binds only resources emits only that
+// key.
+type BindingSlots struct {
+ Resources map[string]ResourceBinding `json:"resources,omitempty"`
+ Applications map[string]IDBinding `json:"applications,omitempty"`
+ Skills map[string]IDBinding `json:"skills,omitempty"`
+}
+
// SkillDefinition is one skill directory discovered under /skills.
// Load only records its slug and location; Compile reads and validates the
// full bundle (skills.go).
@@ -65,10 +154,12 @@ type SkillDefinition struct {
}
// LoadedProject is the fully parsed and validated project directory.
+// Bindings is nil when the project has no bindings.json.
type LoadedProject struct {
Definition ProjectDefinition
Agents []AgentDefinition
Skills []SkillDefinition
+ Bindings *BindingSlots
}
// CompiledProject is the project block of the compiled config.
@@ -76,14 +167,18 @@ type CompiledProject struct {
Name string `json:"name"`
}
-// CompiledAgent is one agent entry of the compiled config.
+// CompiledAgent is one agent entry of the compiled config. Every binding slot
+// the author wrote is already resolved to a platform id here.
type CompiledAgent struct {
- Slug string `json:"slug"`
- Name string `json:"name"`
- Description string `json:"description,omitempty"`
- Model string `json:"model,omitempty"`
- SystemPrompt string `json:"systemPrompt"`
- Env map[string]*string `json:"env,omitempty"`
+ Slug string `json:"slug"`
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ Model string `json:"model,omitempty"`
+ SystemPrompt string `json:"systemPrompt"`
+ Env map[string]*string `json:"env,omitempty"`
+ Connectors []CompiledConnector `json:"connectors,omitempty"`
+ Apps []CompiledApp `json:"apps,omitempty"`
+ Skills []CompiledSkillRef `json:"skills,omitempty"`
}
// CompiledSkillBundle locates a skill's zipped bundle in S3. The CLI never
@@ -108,7 +203,9 @@ type CompiledSkill struct {
// CompiledConfig is the canonical compile output. The platform stores this
// JSON on project_versions.compiled_config and deploys read it verbatim.
// Agents and Skills are optional: an empty project omits both keys rather
-// than emitting "[]".
+// than emitting "[]". bindings.json itself is NOT carried here - compile
+// resolves every slot to a platform id inline on the agents that reference
+// them, so the slot manifest has no consumer downstream.
type CompiledConfig struct {
ConfigVersion int `json:"configVersion"`
Project CompiledProject `json:"project"`