Docs menu

Code extensions

A code extension is a TypeScript module in your project, usually in src/extensions/. Use one when no MCP server does what you need, such as calling your own ticketing API.

Example

// src/extensions/laptops.ts
import { defineExtension, defineTool, Type } from "deskhand";

export default defineExtension({
  name: "laptops",
  tools: [
    defineTool({
      name: "request_loaner",
      description: "Request a loaner laptop for the user.",
      parameters: Type.Object({ reason: Type.String() }),
      write: true,
      summary: (args) => `Request a loaner laptop: "${args.reason}"`,
      execute: async (args) => ({ output: [{ type: "text", text: `Requested (${args.reason}).` }] }),
    }),
  ],
});

Install it

Import the extension in src/main.ts and add it to extensions:

import { start } from "deskhand";
import jira from "./extensions/jira.ts";
import laptops from "./extensions/laptops.ts";

await start({ config: "config/config.yaml", data: "data", extensions: [jira, laptops] });

Only the extensions in that list are installed. For a real API call, see src/extensions/jira.ts in the starter. It adds a read-only jira_search tool and an approval-gated jira_create_issue that notes the requester in the issue. Its tools are added only when JIRA_BASE_URL, JIRA_EMAIL, and JIRA_API_TOKEN are set; otherwise it adds nothing.

defineTool

OptionMeaning
name, descriptionWhat the model sees.
parametersArguments, as a Type.Object(...) schema. They are validated before execute runs.
execute(args, api, context)Does the work and returns { output: [{ type: "text", text }] }. Throwing gives the model an error.
write: trueThe requester must approve each call first. See Approvals. Tools without it must be safe to run again after a restart.
summary(args)One line shown on the approval card.

The requester

Approval only means the requester confirmed the action, not that they may do it. A write tool must act only on the requester’s own account, or check for itself that the requester is allowed. Get the requester with requester(api, context):

import { defineTool, requester, Type } from "deskhand";

defineTool({
  name: "request_loaner",
  description: "Request a loaner laptop for the user.",
  parameters: Type.Object({ reason: Type.String() }),
  write: true,
  execute: async (args, api, context) => {
    const user = await requester(api, context);
    const who = user?.name ?? user?.id ?? "unknown";
    return { output: [{ type: "text", text: `Requested for ${who} (${args.reason}).` }] };
  },
});

It returns a ChatUser, { id, name? }: the chat platform’s user ID and display name, or undefined if there is no chat message in the conversation. The ChatUser type is exported from deskhand too.

Running extensions

  • Files run as TypeScript directly in Node 24, which strips types. Use only erasable syntax: no enum or parameter properties.
  • Variables from .env are available in process.env.
  • Extensions are part of the image. Rebuild after changing one:
docker compose -f deploy/docker-compose.yml up -d --build