Datasette Plugin Writer logo

Datasette Plugin Writer

Community
simonw
datasette-plugin-writer

Guide for writing Datasette plugins. This skill should be used when users want to create or develop plugins for Datasette, including information about plugin hooks, the cookiecutter template, database APIs, request/response handling, and plugin configuration.

Overview

Publishersimonw
Repositoryresearch
Skill namedatasette-plugin-writer
Stars
782
Forks
54
Bundled files
1
LicenseApache-2.0
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • 1 bundled files

    Scripts, templates, and references the model can read while it works. Files are read-only and never executed.

  • Open source

    Published by simonw on GitHub. Read the source before you install it.

Installation

Install the Datasette Plugin Writer AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
    https://github.com/simonw/research/tree/main/datasette-plugin-skill
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/simonw/research.git /tmp/research
mkdir -p .claude/skills
cp -r /tmp/research/datasette-plugin-skill .claude/skills/datasette-plugin-writer
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Datasette Plugin Writer in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Datasette Plugin Writer on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Datasette Plugin Writer is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

Writing Datasette Plugins

Use this skill to build plugins for Datasette, the open source multi-tool for exploring and publishing data.

Quick Start with Cookiecutter Template

Start a new plugin using the datasette-plugin cookiecutter template with newline-delimited variables:

bash
echo "plugin_name
description of plugin
plugin-hyphenated-name
plugin_underscored_name
github_username
Author Name
y
y" | uvx cookiecutter gh:simonw/datasette-plugin

Example for a plugin called "my-cool-plugin":

bash
echo "my cool plugin
A plugin that does cool things
my-cool-plugin
my_cool_plugin
username
Your Name
y
y" | uvx cookiecutter gh:simonw/datasette-plugin

The last two y responses enable static/ and templates/ directories.

After creating the plugin:

bash
cd datasette-my-cool-plugin
python -m venv venv
source venv/bin/activate
pip install -e '.[test]'
datasette plugins  # Verify plugin is visible
python -m pytest   # Run tests

Plugin Structure

A typical plugin structure:

datasette-my-plugin/
├── datasette_my_plugin/
│   ├── __init__.py        # Plugin hooks go here
│   ├── static/            # Optional: CSS, JavaScript
│   └── templates/         # Optional: Custom templates
├── tests/
│   └── test_my_plugin.py
├── setup.py or pyproject.toml
└── README.md

Essential Plugin Hooks

prepare_connection(conn, database, datasette)

Register custom SQL functions. Called when SQLite connections are created:

python
from datasette import hookimpl

@hookimpl
def prepare_connection(conn):
    conn.create_function("hello_world", 0, lambda: "Hello world!")

register_routes(datasette)

Add custom URL routes. Return list of (regex, view_function) pairs:

python
from datasette import hookimpl, Response

async def my_page(request):
    return Response.html("<h1>Hello!</h1>")

@hookimpl
def register_routes():
    return [
        (r"^/-/my-page$", my_page)
    ]

View functions can accept: datasette, request, scope, send, receive.

render_cell(row, value, column, table, database, datasette, request)

Customize how table cell values are displayed:

python
from datasette import hookimpl
import markupsafe

@hookimpl
def render_cell(value, column):
    if column == "stars":
        return markupsafe.Markup("⭐" * int(value))

extra_template_vars(template, database, table, columns, view_name, request, datasette)

Add variables to template context:

python
@hookimpl
def extra_template_vars(request, datasette):
    return {
        "user_agent": request.headers.get("user-agent"),
        "custom_data": "value"
    }

Can also return async functions for database queries.

table_actions(datasette, actor, database, table, request)

Add menu items to table pages:

python
@hookimpl
def table_actions(datasette, database, table):
    return [{
        "href": datasette.urls.path(f"/-/export/{database}/{table}"),
        "label": "Export this table",
        "description": "Download as CSV"
    }]

actor_from_request(datasette, request)

Implement authentication. Return actor dict or None:

python
@hookimpl
def actor_from_request(request):
    token = request.args.get("_token")
    if token == "secret":
        return {"id": "user123", "name": "Alice"}

Can return async function for database lookups.

permission_allowed(datasette, actor, action, resource)

Control permissions. Return True (allow), False (deny), or None (no opinion):

python
@hookimpl
def permission_allowed(actor, action, resource):
    if action == "execute-sql" and actor and actor.get("id") == "admin":
        return True

Request and Response Objects

Request Object

Available in many plugin hooks:

python
request.method          # "GET" or "POST"
request.url             # Full URL
request.path            # Path without query string
request.full_path       # Path with query string
request.query_string    # Query string without ?
request.args            # MultiParams object for query params
request.args.get("key") # Get single param value
request.args.getlist("key")  # Get list of values
request.headers         # Dict of headers (lowercase keys)
request.cookies         # Dict of cookies
request.actor           # Current authenticated actor or None
request.url_vars        # Variables from URL regex

# Async methods:
body = await request.post_body()      # Raw POST body as bytes
form_vars = await request.post_vars() # Form data as dict

Response Object

Create responses in view functions:

python
from datasette.utils.asgi import Response

# HTML response
return Response.html("<h1>Hello</h1>")

# JSON response
return Response.json({"status": "ok"})

# Text response
return Response.text("Plain text")

# Redirect
return Response.redirect("/other-page")

# Custom response
return Response(
    body="Content",
    status=200,
    headers={"X-Custom": "value"},
    content_type="text/plain"
)

# Set cookies
response = Response.html("<h1>Hello</h1>")
response.set_cookie("session", datasette.sign({"id": "123"}, "cookie"))

Database API

Access databases in plugins:

python
# Get database object
db = datasette.get_database("mydb")  # Named database
db = datasette.get_database()        # First database

# Execute read query
results = await db.execute("SELECT * FROM mytable WHERE id = ?", [123])
for row in results:
    print(row["column_name"])

# Query properties
results.rows         # List of Row objects
results.columns      # List of column names
results.truncated    # True if results were truncated
results.first()      # First row or None
results.single_value()  # Single value if query returned one

# Execute write query
await db.execute_write(
    "INSERT INTO mytable (name) VALUES (?)",
    ["value"]
)

# Execute multiple writes
await db.execute_write_many(
    "INSERT INTO mytable (id, name) VALUES (?, ?)",
    [(1, "Alice"), (2, "Bob")]
)

# Execute function with write connection
def insert_and_count(conn):
    conn.execute("INSERT INTO mytable (name) VALUES (?)", ["Alice"])
    return conn.execute("SELECT COUNT(*) FROM mytable").fetchone()[0]

count = await db.execute_write_fn(insert_and_count)

# Introspection
tables = await db.table_names()
views = await db.view_names()
columns = await db.table_columns("mytable")
exists = await db.table_exists("mytable")

Plugin Configuration

Users configure plugins in datasette.yaml:

yaml
plugins:
  datasette-my-plugin:
    api_key: secret123
    enabled: true

Or per-database:

yaml
databases:
  mydb:
    plugins:
      datasette-my-plugin:
        setting: value

Access in plugin code:

python
config = datasette.plugin_config("datasette-my-plugin")
api_key = config.get("api_key") if config else None

# With database/table context
config = datasette.plugin_config(
    "datasette-my-plugin",
    database="mydb",
    table="mytable"
)

Configuration lookup: table → database → instance level.

Static Assets and Templates

Static Files

Place in static/ directory, reference with:

python
# In Python
url = datasette.urls.static_plugins("datasette_my_plugin", "app.js")

# In templates
<script src="{{ urls.static_plugins('datasette_my_plugin', 'app.js') }}"></script>

Templates

Place in templates/ directory. Override Datasette templates:

  • database.html - Database page
  • table.html - Table page
  • row.html - Row page
  • query.html - Query page

Access template functions:

jinja
{{ csrftoken() }}                    {# CSRF token for forms #}
{{ urls.instance() }}                {# Homepage URL #}
{{ urls.database("mydb") }}          {# Database URL #}
{{ urls.table("mydb", "mytable") }}  {# Table URL #}

Common Patterns

Add a custom SQL function

python
@hookimpl
def prepare_connection(conn):
    import hashlib
    def md5(text):
        return hashlib.md5(text.encode()).hexdigest()
    conn.create_function("md5", 1, md5)

Add a custom page

python
@hookimpl
def register_routes(datasette):
    async def stats_page(request):
        db = datasette.get_database()
        tables = await db.table_names()
        return Response.html(
            await datasette.render_template(
                "stats.html",
                {"tables": tables},
                request=request
            )
        )

    return [(r"^/-/stats$", stats_page)]

Render custom output format

python
@hookimpl
def register_output_renderer(datasette):
    def render_csv(columns, rows):
        import csv, io
        output = io.StringIO()
        writer = csv.writer(output)
        writer.writerow(columns)
        writer.writerows(rows)
        return Response(
            output.getvalue(),
            content_type="text/csv"
        )

    return {
        "extension": "csv",
        "render": render_csv
    }

Check permissions

python
@hookimpl
def register_routes(datasette):
    async def admin_page(request):
        # Check if user has permission
        allowed = await datasette.permission_allowed(
            request.actor,
            "admin-page",
            default=False
        )
        if not allowed:
            from datasette import Forbidden
            raise Forbidden("Admin access required")

        return Response.html("<h1>Admin Page</h1>")

    return [(r"^/-/admin$", admin_page)]

URL Design

Use /-/ prefix to avoid conflicts with database names:

  • /-/my-plugin - Instance-level page
  • /dbname/-/my-plugin - Database-level page
  • /dbname/table/-/my-plugin - Table-level page

Build URLs with base_url support:

python
datasette.urls.path("/-/my-page")
datasette.urls.database("mydb")
datasette.urls.table("mydb", "mytable")

Testing Plugins

Create tests in tests/test_my_plugin.py:

python
from datasette.app import Datasette
import pytest

@pytest.mark.asyncio
async def test_my_plugin():
    datasette = Datasette()
    await datasette.invoke_startup()

    # Test with client
    response = await datasette.client.get("/-/my-page")
    assert response.status_code == 200

    # Test database operations
    db = datasette.get_database()
    result = await db.execute("SELECT hello_world()")
    assert result.first()[0] == "Hello world!"

Run tests: pytest

Publishing

To GitHub

bash
git init
git add .
git commit -m "Initial commit"
git branch -m main
git remote add origin git@github.com:username/datasette-my-plugin.git
git push -u origin main

To PyPI

Configure GitHub release environment with PyPI trusted publisher, then create a GitHub release matching your version number. The GitHub Action will automatically publish to PyPI.

Key Resources

Important Notes

  • Accept only the parameters you need in hook functions (dependency injection)
  • Use @hookimpl decorator for all plugin hooks
  • Async functions need async def and await
  • CSRF tokens required for POST forms: <input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
  • Plugin package name uses underscores, pip name uses hyphens
  • Use - prefix in URLs: /-/plugin-path
  • Access internal database with datasette.get_internal_database()

Bundled files

The model reads these on demand while the skill is loaded. They are exposed as readable files and are never executed.

Frequently asked questions

What does the Datasette Plugin Writer AI skill do?

Guide for writing Datasette plugins. This skill should be used when users want to create or develop plugins for Datasette, including information about plugin hooks, the cookiecutter template, database APIs, request/response handling, and plugin configuration.

Why use Datasette Plugin Writer on TypingMind?

Because you install it once and use it with any model. Datasette Plugin Writer is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Datasette Plugin Writer in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/simonw/research/tree/main/datasette-plugin-skill. TypingMind reads its SKILL.md and bundles its files and installs it as a skill you can enable per chat.

Which AI models can use Datasette Plugin Writer?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Datasette Plugin Writer?

As many as you like. As long as a model supports skills, you can use Datasette Plugin Writer with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Datasette Plugin Writer AI skill free?

Yes. It is published on GitHub by simonw under the Apache-2.0 license. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇