---
name: uli
description: Train a small LoRA adapter on a labelled chat dataset through a uli hub, follow the job to its end, grade the adapter against its base model, and chat with it. Use it when the user wants a fine-tuned classifier or responder on their own Mac fleet and the uli MCP tools (uli_check_dataset, uli_submit_job, uli_job_status, uli_eval, uli_chat) are available.
---

# Train and use a uli adapter

This skill drives a uli hub through its MCP tools. A hub queues training jobs, hands each to a Mac node that holds the base model, and serves the finished adapter as BASE:NAME.

## Where the hub and the key come from

The MCP server reads the hub address from the ULI_HUB environment variable. It falls back to http://127.0.0.1:8080. It reads the key from ULI_API_KEY, else from the file ~/.uli/hub/api-key on the machine that runs the server.

If a tool answers that no key was found, the hub key lives on another machine. Ask the user for it or for the right environment. Do not guess a key.

## The dataset

A dataset is a file of OpenAI chat JSONL, one JSON object per line. Each object has exactly one key, messages. The list holds an optional system message, then one user message, then one assistant message, in that order.

Each message has exactly the keys role and content, and content is a string. The assistant content is the label the model learns to produce. Keep it short, because eval compares the first line of the answer to it exactly.

Write the file yourself and pass its absolute path. Tools take paths, never inline data. Keep a second file of held-out rows in the same format for grading. Never put a held-out row in the training file.

## The flow

1. Call uli_check_dataset on the training file. Read the row and label counts. If bad_row_count is above zero, fix the listed lines and check again. uli_submit_job refuses a dataset with bad rows.
2. Call uli_list_nodes. Pick a base that a live node holds. A job for a base with no live node stays queued.
3. Call uli_submit_job with the path, the base, and a name of lowercase letters, digits, dots, dashes and underscores. Optional epochs, learning_rate and seed override the defaults of 3 epochs, 0.0001 and 0. Keep the returned job_id.
4. Poll uli_job_status with the id or the name every 60 seconds, not faster. Training takes minutes to hours. After each poll tell the user the state, the percent and eta_seconds. A missing eta_seconds means the hub cannot estimate yet.
5. When the state is succeeded, call uli_eval on the adapter, given as BASE:NAME, with the held-out file. Then call uli_eval on the plain base on the same file with the same every. Report both accuracies. An adapter figure alone proves nothing, because the base may already do well.
6. Pass record with the job name on the adapter run so the hub stores the result. Record the base run too, since the hub keeps one result for each side.
7. Call uli_chat with model BASE:NAME and a message to show the adapter at work.

uli_eval sends one request per graded row, and every defaults to 5. 100 graded rows take about a minute through a live node. If the client gave a progress token, the tool reports progress per row.

uli_eval also takes an optional `system`, sent before each row. A base model has not seen the labels, so eval the base with the label list as `system`: every label from the training file, one per line. Eval the adapter without `system`, because it learned the labels. The adapter keeps its own system text.

If the state is failed, read the error field of uli_job_status and tell the user. If it is cancelled, say so and ask before submitting again.

## Cancelling

uli_cancel_job stops a job by id or name. The hub has one key, so it stops the job for everyone, including jobs another person started. Cancel only a job you submitted in this session, or one the user names. Ask first when unsure.

## Errors and what to do

A hub error comes back as a tool error that contains `hub error <code>: <message>`.

- invalid_dataset: a row breaks the rule. Run uli_check_dataset and fix the lines.
- invalid_name: the job name breaks the pattern. Choose a lowercase name of at most 64 characters.
- name_taken: a job with that name exists, finished or not. Choose a new name.
- job_not_found: no job has that id or name. Call uli_list_jobs.
- job_finished: the job is no longer running, so it cannot be cancelled. Read its state.
- no_node_for_model: no live node holds that base. Call uli_list_nodes and tell the user.
- node_unreachable: the hub could not reach the node. Wait a minute and retry once, then tell the user.
- adapter_training: the adapter's job has not finished. Keep polling uli_job_status.
- unauthorized: the key is wrong. Ask the user for the right one.

For any other code, quote it to the user and do not retry in a loop.
