Markup is an online annotation tool that can be used to transform unstructured documents into structured formats for NLP and ML tasks, such as named-entity recognition. Markup learns as you annotate to predict and suggest complex annotations, and also provides integrated access to common and custom ontologies for concept mapping.
-
Predictive annotation - Markup's machine learning-powered predictive annotation feature suggests complex annotations as you work, making the process of annotating documents more efficient and saving you valuable time.
-
Integrated ontology access Markup provides integrated access to a wide range of common ontologies (e.g. UMLS, SNOMED-CT, ICD-10), as well as the ability to upload custom ontologies, for concept mapping.
-
Predictive ontology mapping - Markup's predictive ontology mapping feature uses machine learning to suggest appropriate mappings to standard and custom terminologies based on the text you're annotating.
-
User-friendly interface - Whether you're a technical expert or a beginner, Markup's user-friendly interface makes it easy for anyone to start annotating documents with minimal setup.
To install and run Markup locally:
- Clone the repository and install dependencies:
git clone https://github.com/samueldobbie/markup && cd markup && pnpm install - Copy
.env.exampleto.env.local - Install the Supabase CLI
- Start Supabase:
supabase start. This will output an API URL, anon key, and service role key. Add the URL and anon key to.env.localasVITE_SUPABASE_URLandVITE_SUPABASE_ANON_KEY, and the service role key asSUPABASE_SERVICE_ROLE_KEY. SetMODEL_CREDENTIALS_KEYto any secret used to encrypt workspace API keys (or a 64-character hex key). - Run the web app and API:
pnpm devandpnpm dev:api - Open Markup in your web browser at
http://localhost:3000
To use AI suggestions, open a workspace, click Configure AI, and pick Claude or OpenAI (or Custom for a local / OpenAI-compatible server). Highlighting text in the annotate view will suggest an entity and attributes. The Suggested tab asks the same model for document-level annotations you can accept or dismiss.
Markup does not call a Markup-hosted model. Each workspace points at your HTTP API. That API must speak the OpenAI Chat Completions protocol.
Markup sends:
POST {base URL}/chat/completions
Content-Type: application/json
Authorization: Bearer {API key} # omitted if you leave the API key blank
Do not include /chat/completions in the base URL. These are equivalent:
| Base URL | Request URL |
|---|---|
https://api.openai.com/v1 |
https://api.openai.com/v1/chat/completions |
http://127.0.0.1:8080 |
http://127.0.0.1:8080/chat/completions |
Request body:
{
"model": "gpt-4o-mini",
"temperature": 0,
"messages": [
{ "role": "system", "content": "You are a JSON API. Respond with valid JSON only. ..." },
{ "role": "user", "content": "<suggestion prompt>" }
]
}model is omitted when the workspace model name is blank. Your server can ignore model if it only serves one model.
Markup first tries response_format: { "type": "json_object" }. If that returns HTTP 400, it retries without response_format. Either way, the assistant message must be JSON (optionally wrapped in a ```json fence).
Expected response:
{
"choices": [
{
"message": {
"content": "{\"entity\": \"Prescription\"}"
}
}
]
}Entity suggestions parse {"entity": "<name>"}. Attribute suggestions parse a flat object of string values, for example {"Dose": "10", "Unit": "mg"}. Document-level suggestions parse {"annotations":[{"entity":"<name>","text_span":"<exact substring>","attributes":{}}]}. Markup resolves text_span against the document and drops anything not in the workspace config, not present in the span, or overlapping an existing annotation.
Examples:
| Provider | Base URL | Model | API key |
|---|---|---|---|
| Claude | https://api.anthropic.com/v1 |
claude-sonnet-5 |
sk-ant-... |
| OpenAI | https://api.openai.com/v1 |
gpt-5.6-sol |
sk-... |
| Ollama (Custom) | http://127.0.0.1:11434/v1 |
llama3.2 |
blank or any placeholder |
| Single-model proxy (Custom) | https://your-proxy.example |
blank | blank, or Bearer token if you require auth |
A local or private endpoint with no model name and no key is valid: set only the base URL.
Markup is deployed to Render as a Docker web service built from the Dockerfile. The container serves the API and the built frontend from the same process. Render redeploys automatically when main is updated.
Set these environment variables on the Render service:
| Variable | Used by |
|---|---|
SUPABASE_URL |
API server |
SUPABASE_SERVICE_ROLE_KEY |
API server (use an sb_secret_... key) |
MODEL_CREDENTIALS_KEY |
API server (encrypts workspace API keys; changing it makes saved keys unreadable) |
VITE_SUPABASE_URL |
Frontend build |
VITE_SUPABASE_ANON_KEY |
Frontend build (the publishable key) |
Render passes the service's environment variables to the Docker build as build arguments, so the VITE_ values are baked into the frontend at build time. Changing them requires a redeploy.
To get started with Markup, read the quick start guide.
Contributions to Markup are appreciated. If you'd like to contribute, please follow these guidelines:
- Fork the repository
- Create a new branch for your feature
- Make your changes
- Submit a pull request for review
If you have any questions or need assistance with Markup, you can contact me at sam@getmarkup.com.
