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.
The Fly.io app serves the API and the built frontend from the same process. Set these secrets on the machine:
fly secrets set SUPABASE_URL=... SUPABASE_SERVICE_ROLE_KEY=... MODEL_CREDENTIALS_KEY=...
VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY are baked in at image build time, so pass them as build arguments:
fly deploy --build-arg VITE_SUPABASE_URL=... --build-arg VITE_SUPABASE_ANON_KEY=...
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.