mdlang

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.

Get started$ npm install mdlang
actions/create-user.mdwhat you write
---
name: create-user
The name your code will call.
description: Create a user account.
mcpTools:
The only tools it is allowed to use.
  - Postgres: [insert]
customTools: [hashPassword, sendEmail]
args:
What the caller passes in.
  - name: email
    type: string
    required: true
  - name: password
    type: string
    required: true
  - name: plan
A default makes it optional.
    type: string
    default: free
output:
The shape you get back — a schema, not a hope.
  type: object
  items:
    - name: id
      type: string
    - name: welcomeSent
      type: boolean
---
Hash the password, insert the user, then
What the agent should do.
send the welcome email.
npx mdlang generate
src/signup.tswhat you call
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.

What starts it
SkillClaude loads it when it judges the task relevant
ActionYour signup handler calls it by name
What comes back
SkillProse, files, whatever the work produced
Action{ id: string; welcomeSent: boolean }
Arguments
SkillRead from the surrounding conversation
ActionDeclared in frontmatter, validated before the run
Tool access
SkillWhatever the surrounding agent can already reach
ActionPostgres insert and two custom tools — nothing else
Where it runs
SkillClaude apps, Claude Code, or an agent on the API
ActionInside your service, behind your own endpoint
What you ship
SkillA folder of Markdown the model reads
ActionA typed TypeScript client your build step emits

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.

  1. Step 1

    Write it

    actions/create-user.md, in your repo, reviewed like any other file.

    npx mdlang init
  2. 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
  3. 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.

Step 1 · hash
In the transcript
hashPassword({ raw: "$password" })
What actually happens
hashPassword({ raw: "correct-horse-battery…" })

The password enters the run as a name. The model routes it without ever reading it.

Step 2 · capture
In the transcript
ok. captured: $hashedPassword
What actually happens
context: { $hashedPassword: "$2b$10$9x4KpQr7v…" }

contextOutput captures the hash. The agent is told a value exists, not what it is.

Step 3 · insert
In the transcript
insert({ into: "users", values: {  email: "$email",  password_hash: "$hashedPassword",} })
What actually happens
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.

POST /users

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.

Programmatic API
server.ts
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