Write it in Markdown.
Call it like a function.
One file declares the arguments, the result, and the tools an action may touch. Your code calls it by name.
$ npm install mdlang---name: create-userdescription: Create a user account.mcpTools: - Postgres: [insert]customTools: [hashPassword, sendEmail]args: - name: email type: string required: true - name: password type: string required: true - name: plan type: string default: freeoutput: type: object items: - name: id type: string - name: welcomeSent type: boolean---Hash the password, insert the user, thensend the welcome email.const { id, welcomeSent } = await action("create-user", { email: "ada@example.com", password: form.password,});id and welcomeSent are typed from the file. A name that doesn’t exist won’t compile, and a missing password is rejected before the model is ever called.
Why declare it
Predictable results from a model that isn't.
An action is a contract. The Markdown says what goes in, what comes out, and what the agent may touch — and the generated client holds it to all three.
A signature, not a prompt
The generated client knows create-user takes an email and a password and returns an id. Autocomplete works; a renamed field is a type error, not a 3am surprise.
Checked before the model runs
Call it without an email and it throws immediately, naming the field. The request never reaches your provider and never costs a token.
Tools that can't drift
create-user can insert rows and send mail. It cannot drop a table, because dropping isn't in its frontmatter and there is no ambient registry to fall back on.
One file, whole definition
Instructions, inputs, outputs and permissions sit together and version together. There's no second place to look when you need to know what it does.
If you already know Agent Skills
Same instinct. Different job.
A Skill teaches a model how to work. An action gives your service something to call.
{ id: string; welcomeSent: boolean }Use a Skill when you want an assistant to work the way your team works. Use an action when a service needs a capability it can call, log, retry, and put behind an endpoint.
The loop
A file, a build step, a function call.
- Step 1
Write it
actions/create-user.md, in your repo, reviewed like any other file.
npx mdlang init - Step 2
Compile it
Codegen checks that Postgres really exposes insert. If it doesn't, the action is skipped with a warning instead of shipped.
npx mdlang generate - Step 3
Call it
Arguments validated, agent built with your model, only this action's tools attached, declared shape returned.
await action("create-user", { … })
Context variables
The password never reaches the model.
The model works with the names of your values. The runtime supplies the values.
Follow one create-user run. Arguments enter as $-prefixed variables; real values are substituted the instant before each tool call executes.
hashPassword({ raw: "$password" })hashPassword({ raw: "correct-horse-battery…" })The password enters the run as a name. The model routes it without ever reading it.
ok. captured: $hashedPasswordcontext: { $hashedPassword: "$2b$10$9x4KpQr7v…" }contextOutput captures the hash. The agent is told a value exists, not what it is.
insert({ into: "users", values: { email: "$email", password_hash: "$hashedPassword",} })insert({ into: "users", values: { email: "ada@example.com", password_hash: "$2b$10$9x4KpQr7v…",} })Real values are substituted the moment before the call executes.
Secrets stay out of provider logs
A value that never enters the transcript can't be retained by anyone downstream.
Token cost stops tracking data size
Routing a short id and routing a 40KB document cost the same, because the model sees the same thing.
References resolve before you get them
Any $ left in the final result is substituted before action() returns, so a caller never sees a variable name.
In practice
Put it behind an endpoint.
An action is ordinary async TypeScript, so it drops into a route handler with nothing around it. A bad body is a validation error before an agent starts, and isActionName narrows an arbitrary string when you want to dispatch by name.
app.post("/users", async (req, res) => { const { id } = await action("create-user", { email: req.body.email, password: req.body.password, }); res.status(201).json({ id });});Start with one action.
npx mdlang init writes a config, a system prompt, and an example you can run straight away.
Node 20+ · ESM · peers @langchain/core and zod · MIT