@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 +37 -37
- package/package.json +1 -1
- package/src/init.js +11 -5
- package/src/tools.js +56 -22
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
|
|
8
|
-
├── env.md
|
|
9
|
-
└── tools/
|
|
10
|
-
├── built
|
|
11
|
-
|
|
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:
|
|
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
|
|
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
|
|
39
|
-
├── env.md
|
|
40
|
-
└── tools/
|
|
41
|
-
├── built
|
|
42
|
-
|
|
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
|
|
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
|
|
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`.
|
|
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
|
-
|
|
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:
|
|
82
|
-
description:
|
|
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
|
-
| `
|
|
96
|
+
| `city` | `string` | City name | `true` |
|
|
90
97
|
|
|
91
98
|
## Code
|
|
92
99
|
|
|
93
100
|
```javascript
|
|
94
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
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 {
|
|
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
|
|
28
|
-
//
|
|
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
|
-
|
|
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}/${
|
|
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
|
-
//
|
|
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
|
|
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"])
|
|
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
|
-
//
|
|
87
|
-
|
|
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
|
|
92
|
-
if (
|
|
93
|
-
throw new FileError(
|
|
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((
|
|
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
|
-
|
|
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
|
|
168
|
-
//
|
|
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
|
|
172
|
-
if (
|
|
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
|
-
|
|
176
|
-
|
|
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))) {
|