@soft-boy/agentschema 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,22 +4,20 @@ Define an AI-Agent as a folder of Markdown files.
4
4
 
5
5
  ```
6
6
  my-agent/
7
- ├── agent.md # the agent
8
- ├── env.md # the environment it runs in
9
- └── tools/ # one file per tool, in any folders you like
10
- ├── built-in/
11
- │ ├── bash.md
12
- │ └── read.md
13
- └── read_file.md
7
+ ├── agent.md # the agent
8
+ ├── env.md # the environment it runs in
9
+ └── tools/ # tool files, in any folders you like
10
+ ├── agent_toolset_20260401.md # the pre-built tools to enable
11
+ └── get_weather.md
14
12
  ```
15
13
 
16
- **Status:** v0.2 draft. v0.2 breaks from the Claude Managed Agents schema: tools move out of `agent.md` and into `tools/`.
14
+ **Status:** v0.2.1 draft. v0.2 breaks from the Claude Managed Agents schema: tools move out of `agent.md` and into `tools/`.
17
15
 
18
16
  ## Philosophy
19
17
 
20
18
  1. **One folder, one agent.** A folder defines one agent and the one environment it runs in. Sessions, credentials, mounted files and repositories, and schedules are supplied by whatever runs the agent.
21
19
  2. **Everything is a Markdown file.** An agent is plain files you can read, diff, review and version like any other code. Structured settings go in YAML frontmatter, and prose the model reads goes in the Markdown body.
22
- 3. **Inspired by Claude Managed Agents, not tied to it.** `agent.md` and `env.md` use the Claude Managed Agents fields because they're a well-documented vocabulary for agents. Tools are the exception: each one is its own file, and agentschema compiles them into the API's format. The format itself assumes no particular infrastructure, so any runtime that can read the files can run the agent.
20
+ 3. **Inspired by Claude Managed Agents, not tied to it.** `agent.md` and `env.md` use the Claude Managed Agents fields because they're a well-documented vocabulary for agents. Tools are the exception: they're split into their own files, and agentschema compiles them into the API's format. The format itself assumes no particular infrastructure, so any runtime that can read the files can run the agent.
23
21
 
24
22
  ## Install
25
23
 
@@ -31,17 +29,15 @@ npm install -g @soft-boy/agentschema
31
29
 
32
30
  ### Files
33
31
 
34
- An instance is a folder of Markdown files. `agent.md` and `env.md` are required; `tools/` is optional.
32
+ An instance is a folder of Markdown files. `agent.md` and `env.md` are required.
35
33
 
36
34
  ```
37
35
  my-agent/
38
- ├── agent.md # the agent
39
- ├── env.md # the environment it runs in
40
- └── tools/ # one file per tool, in any folders you like
41
- ├── built-in/
42
- │ ├── bash.md
43
- │ └── read.md
44
- └── read_file.md
36
+ ├── agent.md # the agent
37
+ ├── env.md # the environment (computer)
38
+ └── tools/ # tool files, nested however you like
39
+ ├── agent_toolset_20260401.md # the pre-built tools to enable
40
+ └── get_weather.md
45
41
  ```
46
42
 
47
43
  ### `agent.md`
@@ -59,56 +55,60 @@ Field reference: [Environments](https://platform.claude.com/docs/en/managed-agen
59
55
 
60
56
  ### `tools/`
61
57
 
62
- Each `.md` file in `tools/` is one tool, and the file name is the tool's name. agentschema supports two kinds: pre-built and custom.
58
+ Each `.md` file in `tools/` is either the pre-built toolset or one custom tool.
63
59
 
64
- Subfolders are only for organizing. Every `.md` file under `tools/`, at any depth, is a tool, and its folder is ignored when compiling: moving a file between folders changes nothing that's deployed. Because the folders are dropped, two tools can't share a name even in different folders. Folders and files whose names start with `.` are skipped. By convention, the pre-built tools go in `tools/built-in/`.
60
+ Subfolders are only for organizing. Every `.md` file under `tools/`, at any depth, is read, and its folder is ignored when compiling: moving a file between folders changes nothing that's deployed. Because the folders are dropped, two custom tools can't share a name even in different folders. Folders and files whose names start with `.` are skipped.
65
61
 
66
- **Pre-built tools** are the tools Claude Managed Agents runs in the container: `bash`, `edit`, `glob`, `grep`, `read`, `web_fetch`, `web_search` and `write`. Name the file after the tool (for example `tools/built-in/bash.md`), and give it only a `type`:
62
+ **Pre-built tools** are the tools Claude Managed Agents runs in the container: `bash`, `edit`, `glob`, `grep`, `read`, `web_fetch`, `web_search` and `write`. One file lists the ones the agent gets. By convention it's `tools/agent_toolset_20260401.md`, but the file name isn't used:
67
63
 
68
64
  ```markdown
69
65
  ---
70
66
  type: agent_toolset_20260401
71
67
  ---
68
+
69
+ ## Tools
70
+
71
+ | Name | Enabled | Permission |
72
+ |---|---|---|
73
+ | `bash` | `true` | `always_ask` |
74
+ | `read` | `true` | |
75
+ | `web_search` | `false` | |
72
76
  ```
73
77
 
74
- Only the pre-built tools that have a file are enabled; delete a file to disable that tool. The body is a note for people and isn't sent anywhere.
78
+ - **Frontmatter** is only `type: agent_toolset_20260401`.
79
+ - **`## Tools`** has a row for each tool you configure. **Enabled** is `true` or `false`. A tool without a row keeps the API's default, which is enabled. With no file at all, the agent gets no pre-built tools.
80
+ - **Permission** is `always_allow`, `always_ask` or `auto`. Leave the cell blank for the API's default (`always_allow`), or leave out the column entirely.
81
+ - There can be only one such file. Other sections are notes for people.
75
82
 
76
83
  **Custom tools** run on your side: when the agent calls one, `agentschema run` runs its code on your machine and sends back the result.
77
84
 
78
85
  ````markdown
79
86
  ---
80
87
  type: custom
81
- name: read_file
82
- description: Read a file and output its contents as a string.
88
+ name: get_weather
89
+ description: Get the current weather for a city.
83
90
  ---
84
91
 
85
92
  ## Input Schema
86
93
 
87
94
  | Name | Type | Description | Required |
88
95
  |---|---|---|---|
89
- | `filePath` | `string` | The path of the file to read | `true` |
96
+ | `city` | `string` | City name | `true` |
90
97
 
91
98
  ## Code
92
99
 
93
100
  ```javascript
94
- const fs = require('fs').promises;
95
-
96
- module.exports = async ({ filePath }) => {
97
- try {
98
- return await fs.readFile(filePath, 'utf-8');
99
- } catch (error) {
100
- return `Error reading file: ${error.message}`;
101
- }
102
- };
101
+ module.exports = ({ city }) =>
102
+ fetch(`https://wttr.in/${encodeURIComponent(city)}?format=3`).then((r) => r.text());
103
103
  ```
104
104
  ````
105
105
 
106
- - **Frontmatter:** `type: custom`, a `name` that matches the file name, and a `description` the model reads to decide when to call the tool. The name can't reuse a pre-built tool's name (so `read_file`, not `read`), because the API rejects that.
106
+ - **Frontmatter:** `type: custom`, a `name` that matches the file name, and a `description` the model reads to decide when to call the tool. The name can't reuse a pre-built tool's name (so `read_file` works, but not `read`), because the API rejects that.
107
107
  - **`## Input Schema`** is a table with the columns Name, Type, Description and Required. Type is `string`, `number`, `integer`, `boolean`, `object` or `array`, or one of those followed by `[]` for a list. Leave the section out for a tool that takes no input.
108
108
  - **`## Code`** is required, and must be one JavaScript code block. It's a CommonJS module, and `module.exports` is the function. It's called with the call's input as an object. A string it returns (or resolves to) is sent as the result; anything else is sent as JSON. If it throws, the error message is sent as an error result. `require()` resolves from the tool's file, so packages installed in the agent folder work.
109
109
  - Other sections are notes for people.
110
110
 
111
- **Compiling.** `deploy` turns `tools/` into the agent's `tools` list, sorted by tool name: one `agent_toolset_20260401` entry with `default_config: { enabled: false }` and a config enabling each pre-built tool with a file, then the custom tools. A custom tool sends only its name, description and input schema; the code never leaves your machine.
111
+ **Compiling.** `deploy` turns `tools/` into the agent's `tools` list, sorted by tool name: one `agent_toolset_20260401` entry with a config for each row of the `## Tools` table (its `enabled`, and its `permission_policy` if it has one), then the custom tools. A custom tool sends only its name, description and input schema; the code never leaves your machine.
112
112
 
113
113
  ## Usage
114
114
 
@@ -128,7 +128,7 @@ agentschema sessions delete <id> # delete a session
128
128
  **`init`** creates the folder `<name>` with an `agent.md` and an `env.md` that pass `check`. It asks for:
129
129
  - the agent's name and model
130
130
  - its system prompt
131
- - whether it gets the pre-built tools; if so, `init` writes a file for each one in `tools/built-in/`
131
+ - whether it gets the pre-built tools; if so, `init` writes `tools/agent_toolset_20260401.md` listing all eight
132
132
  - the environment's name and networking
133
133
 
134
134
  Press Enter to keep the default shown, or pass `--yes` to skip the questions. `init` won't write into a folder that already has files in it.
@@ -137,7 +137,7 @@ Press Enter to keep the default shown, or pass `--yes` to skip the questions. `i
137
137
  - a missing file, or a name that isn't lowercase
138
138
  - frontmatter that is missing, unclosed, or invalid YAML
139
139
  - a missing `name` or `model`, or a `system` or `tools` key in `agent.md`'s frontmatter
140
- - a tool file that isn't a known pre-built tool, or a custom tool with a missing description, a malformed input table, or code that isn't JavaScript or doesn't parse
140
+ - a pre-built tools table that names an unknown tool or permission, or a custom tool with a missing description, a malformed input table, or code that isn't JavaScript or doesn't parse
141
141
  - values over the API's limits
142
142
  - anything that looks like an API key or token
143
143
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soft-boy/agentschema",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Markdown based AI agent schema",
5
5
  "type": "module",
6
6
  "bin": {
package/src/init.js CHANGED
@@ -3,7 +3,7 @@ import { basename, dirname, resolve } from "node:path";
3
3
  import { createInterface, emitKeypressEvents } from "node:readline";
4
4
  import { stringify } from "yaml";
5
5
  import { AGENT_FILE, ENV_FILE } from "./instance.js";
6
- import { BUILT_IN_DIR, PREBUILT_TOOLS, TOOLSET, TOOLS_DIR } from "./tools.js";
6
+ import { TOOLSET_FILE, PREBUILT_TOOLS, TOOLSET, TOOLS_DIR } from "./tools.js";
7
7
 
8
8
  export class InitError extends Error {}
9
9
 
@@ -24,8 +24,8 @@ export function defaults(dir) {
24
24
  };
25
25
  }
26
26
 
27
- // Returns { path: text } for a set of answers: agent.md, env.md, and a tools/built-in/ file
28
- // for each pre-built tool when the agent gets them.
27
+ // Returns { path: text } for a set of answers: agent.md, env.md, and tools/agent_toolset_20260401.md
28
+ // listing every pre-built tool when the agent gets them.
29
29
  export function scaffold(answers) {
30
30
  const agent = { name: answers.name, model: answers.model };
31
31
 
@@ -41,11 +41,17 @@ export function scaffold(answers) {
41
41
  [ENV_FILE]: `---\n${stringify(env)}---\nCloud container with ${access}.\n`,
42
42
  };
43
43
  if (answers.tools) {
44
- for (const name of PREBUILT_TOOLS) files[`${TOOLS_DIR}/${BUILT_IN_DIR}/${name}.md`] = `---\ntype: ${TOOLSET}\n---\n`;
44
+ files[`${TOOLS_DIR}/${TOOLSET_FILE}`] = builtInFile(PREBUILT_TOOLS);
45
45
  }
46
46
  return files;
47
47
  }
48
48
 
49
+ // The pre-built toolset file, enabling `names` with the API's default permission policy.
50
+ export function builtInFile(names) {
51
+ const rows = names.map((name) => `| \`${name}\` | \`true\` | |`).join("\n");
52
+ return `---\ntype: ${TOOLSET}\n---\n\nThe tools Claude runs in the agent's container. Set Enabled to false to turn a tool off.\n\n## Tools\n\n| Name | Enabled | Permission |\n|---|---|---|\n${rows}\n`;
53
+ }
54
+
49
55
  // Asks the setup questions, then writes agent.md and env.md into a new folder `dir`.
50
56
  export async function init(dir, { yes = false, input = process.stdin, output = process.stdout } = {}) {
51
57
  if (existsSync(dir) && readdirSync(dir).length > 0) {
@@ -61,7 +67,7 @@ export async function init(dir, { yes = false, input = process.stdin, output = p
61
67
  writeFileSync(resolve(dir, file), text);
62
68
  }
63
69
 
64
- const tools = answers.tools ? `, and ${dir}/${TOOLS_DIR}/${BUILT_IN_DIR}/ with the pre-built tools (delete a file to disable that tool)` : "";
70
+ const tools = answers.tools ? `, and ${dir}/${TOOLS_DIR}/${TOOLSET_FILE} with the pre-built tools (set Enabled to false to turn one off)` : "";
65
71
  output.write(`\nCreated ${dir}/${AGENT_FILE} and ${dir}/${ENV_FILE}${tools}.\n\nNext:\n`);
66
72
  output.write(` agentschema deploy ${dir}\n agentschema run ${dir}\n`);
67
73
  return files;
package/src/tools.js CHANGED
@@ -5,13 +5,15 @@ import { Script, runInThisContext } from "node:vm";
5
5
  import { FileError, parseMarkdown } from "./markdown.js";
6
6
 
7
7
  export const TOOLS_DIR = "tools";
8
- export const BUILT_IN_DIR = "built-in";
9
8
  export const TOOLSET = "agent_toolset_20260401";
9
+ export const TOOLSET_FILE = `${TOOLSET}.md`;
10
10
  export const PREBUILT_TOOLS = ["bash", "edit", "glob", "grep", "read", "web_fetch", "web_search", "write"];
11
+ export const POLICIES = ["always_allow", "always_ask", "auto"];
11
12
  const TYPES = ["string", "number", "integer", "boolean", "object", "array"];
12
13
 
13
14
  // Reads every .md file under tools/, in any subfolder, sorted by name. Folders are only for
14
- // organizing: a tool's name is its file name, and its path isn't compiled into anything.
15
+ // organizing: a custom tool's name is its file name, and its path isn't compiled into anything.
16
+ // The pre-built toolset is one file (by convention tools/agent_toolset_20260401.md) that lists its tools.
15
17
  // A missing tools/ folder means no tools.
16
18
  // Returns { tools, errors }; a tool file that can't be parsed has an entry in errors.
17
19
  export function readTools(dir) {
@@ -35,10 +37,14 @@ export function readTools(dir) {
35
37
  }
36
38
  }
37
39
 
38
- // Names must be unique across folders, since the folders are dropped.
39
- for (const [name, same] of groupBy(tools, (t) => t.name)) {
40
+ // Custom tool names must be unique across folders, since the folders are dropped.
41
+ for (const [name, same] of groupBy(tools.filter((t) => t.data.type === "custom"), (t) => t.name)) {
40
42
  if (same.length > 1) errors.push(`${TOOLS_DIR}: more than one tool is named \`${name}\` (${same.map((t) => t.file).join(", ")})`);
41
43
  }
44
+ const toolsets = tools.filter((t) => t.data.type === TOOLSET);
45
+ if (toolsets.length > 1) {
46
+ errors.push(`${TOOLS_DIR}: more than one \`${TOOLSET}\` file (${toolsets.map((t) => t.file).join(", ")}); list every pre-built tool in one file`);
47
+ }
42
48
  return { tools: tools.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)), errors };
43
49
  }
44
50
 
@@ -60,7 +66,8 @@ function groupBy(items, key) {
60
66
  return groups;
61
67
  }
62
68
 
63
- // Pulls the `## Input Schema` table and the `## Code` block out of a tool file's body.
69
+ // Pulls the `## Input Schema` table, the `## Code` block and the `## Tools` table (for the
70
+ // pre-built toolset) out of a tool file's body.
64
71
  function parseSections(body, file) {
65
72
  const sections = {};
66
73
  let current = null;
@@ -77,22 +84,29 @@ function parseSections(body, file) {
77
84
  }
78
85
  }
79
86
 
80
- const result = { params: null, code: null };
81
- if (sections["input schema"]) result.params = parseTable(sections["input schema"], file);
87
+ const result = { params: null, code: null, rows: null };
88
+ if (sections["input schema"]) {
89
+ result.params = parseTable(sections["input schema"], `${file}: the Input Schema table's columns must be Name | Type | Description | Required`,
90
+ ["name", "type", "description", "required"]);
91
+ }
82
92
  if (sections.code) result.code = parseCode(sections.code, file);
93
+ if (sections.tools) {
94
+ result.rows = parseTable(sections.tools, `${file}: the Tools table's columns must be Name | Enabled, or Name | Enabled | Permission`, ["name", "enabled"], ["permission"]);
95
+ }
83
96
  return result;
84
97
  }
85
98
 
86
- // Rows of the Name | Type | Description | Required table.
87
- function parseTable(lines, file) {
99
+ // The rows of a Markdown table as objects keyed by column name. The header must start with
100
+ // the `required` columns, and may go on with the `optional` ones, in order.
101
+ function parseTable(lines, message, required, optional = []) {
88
102
  const rows = lines.filter((l) => l.trim().startsWith("|")).map(splitRow);
89
103
  if (rows.length === 0) return [];
90
104
  const header = rows[0].map((c) => c.toLowerCase());
91
- const want = ["name", "type", "description", "required"];
92
- if (want.some((c, i) => header[i] !== c)) {
93
- throw new FileError(`${file}: the Input Schema table's columns must be Name | Type | Description | Required`);
105
+ const allowed = [...required, ...optional];
106
+ if (header.length < required.length || header.length > allowed.length || header.some((c, i) => c !== allowed[i])) {
107
+ throw new FileError(message);
94
108
  }
95
- return rows.slice(2).map(([name, type, description, required]) => ({ name, type, description, required }));
109
+ return rows.slice(2).map((cells) => Object.fromEntries(header.map((c, i) => [c, cells[i] ?? ""])));
96
110
  }
97
111
 
98
112
  function splitRow(line) {
@@ -121,9 +135,7 @@ export function checkTools(tools, errors) {
121
135
  };
122
136
 
123
137
  if (type === TOOLSET) {
124
- if (!PREBUILT_TOOLS.includes(tool.name)) {
125
- err(`not a pre-built tool; name the file after one of: ${PREBUILT_TOOLS.join(", ")}`);
126
- }
138
+ checkToolset(tool, err);
127
139
  extra(["type"]);
128
140
  } else if (type === "custom") {
129
141
  checkCustom(tool, err);
@@ -134,6 +146,24 @@ export function checkTools(tools, errors) {
134
146
  }
135
147
  }
136
148
 
149
+ function checkToolset(tool, err) {
150
+ if (!tool.rows) {
151
+ // 0.2.0 had one file per pre-built tool, with no table.
152
+ const hint = PREBUILT_TOOLS.includes(tool.name) ? `; since 0.2.1 all pre-built tools go in one file, ${TOOLS_DIR}/${TOOLSET_FILE}` : "";
153
+ return err(`a \`## Tools\` table listing the pre-built tools to enable is required${hint}`);
154
+ }
155
+ const seen = new Set();
156
+ for (const row of tool.rows) {
157
+ if (!PREBUILT_TOOLS.includes(row.name)) err(`\`${row.name}\` is not a pre-built tool; use ${PREBUILT_TOOLS.join(", ")}`);
158
+ else if (seen.has(row.name)) err(`\`${row.name}\` is listed twice`);
159
+ seen.add(row.name);
160
+ if (!["true", "false"].includes(row.enabled.toLowerCase())) err(`\`${row.name}\`: Enabled must be true or false`);
161
+ if (row.permission && !POLICIES.includes(row.permission)) {
162
+ err(`\`${row.name}\` has permission \`${row.permission}\`; use ${POLICIES.join(", ")}, or leave it blank`);
163
+ }
164
+ }
165
+ }
166
+
137
167
  function checkCustom(tool, err) {
138
168
  const { name, description } = tool.data;
139
169
  if (name !== tool.name) err(`\`name\` must match the file name (${tool.name})`);
@@ -164,16 +194,20 @@ function checkCustom(tool, err) {
164
194
  }
165
195
  }
166
196
 
167
- // The tools list sent to the API: one toolset entry that enables only the pre-built tools
168
- // with a file (so deleting a file disables the tool), then the custom tools, sorted by name.
197
+ // The tools list sent to the API: one toolset entry with a config for each row of its table
198
+ // (tools it doesn't list keep the API's defaults), then the custom tools, sorted by name.
169
199
  export function compileTools(tools) {
170
200
  const compiled = [];
171
- const prebuilt = tools.filter((t) => t.data.type === TOOLSET).map((t) => t.name).sort();
172
- if (prebuilt.length > 0) {
201
+ const toolset = tools.find((t) => t.data.type === TOOLSET);
202
+ if (toolset) {
203
+ const rows = [...(toolset.rows ?? [])].sort((a, b) => (a.name < b.name ? -1 : 1));
173
204
  compiled.push({
174
205
  type: TOOLSET,
175
- default_config: { enabled: false },
176
- configs: prebuilt.map((name) => ({ name, enabled: true })),
206
+ configs: rows.map(({ name, enabled, permission }) => {
207
+ const config = { name, enabled: enabled.toLowerCase() === "true" };
208
+ if (permission) config.permission_policy = { type: permission };
209
+ return config;
210
+ }),
177
211
  });
178
212
  }
179
213
  for (const tool of tools.filter((t) => t.data.type === "custom").sort((a, b) => (a.name < b.name ? -1 : 1))) {