Skip to content

Assistants

Two read-only assistants share one worker, one model, and one reply at a time per person. The trip assistant plans a single trip. The general assistant can read the whole account. Neither one changes the catalogue, a trip, or kit. A suggested change is something the photographer applies.

There is no personal API token and no ChatGPT connection. Those stay unbuilt; see future-features.md.

Both are opt-in. A missing per-account row means off, and the global switch is a kill switch on top of that row (console/features.py). An account created with is_admin already set starts with both on. Every other account starts off, including one promoted later (accounts/signals.py). An admin cannot change either switch on their own account. The switches, the shared model, and the optional daily caps are described in accounts-and-admin.md.

Trip assistant

The button sits in the trip header, next to Map and Edit, when AI Trip Assistant is on for the account. The panel is a column beside the itinerary at 1200 px and wider, and a full-screen sheet below that. The address is /app/trips/:id/assistant.

It reads that trip, the itinerary, the locations in the trip's folders, My Kit, the packing list, and forecasts already stored. It does not fetch a new forecast. Refresh forecasts on the trip page uses the normal forecast fetch, two locations at a time, for itinerary locations inside today and the next two days. It is disabled when no trip day is in that window. There is no forecast, sunrise, sunset, or terrain line outside the window. Astro and macro have no specialist block, so any reasoning from cloud, wind, and humidity is labelled as an assumption. The specialist blocks that do exist are the ones it uses. See forecasting.md.

Before it recommends equipment it reads My Kit and the packing list. It recommends from equipment the photographer owns, and it keeps owned, planned for this trip, and packed separate. It says when something useful is owned but not on the trip. Anything the photographer does not own is a suggestion to buy or borrow. A missing specification is unknown. It says that two items fit only when the recorded numbers support it, such as a lens mount against a body mount, or a filter thread against a filter size. Any other compatibility is an assumption. It sees kit that has reached the server. A change still waiting on the phone is not part of that, and the page says so.

Tools, in order (assistant/tools.py): get_trip_context, get_trip_itinerary, get_user_kit, get_trip_kit, list_trip_locations, get_location_details, get_location_forecast, compare_opportunities. Each runs inside a read-only database transaction.

General assistant

Assistant is in the side navigation when AI Assistant is on. It is not on the bottom bar, and folder and location pages do not link to it. The page is /app/assistant. At 1200 px and wider the conversation list sits beside the chat. Below that, the list and the chat are separate screens, /app/assistant and /app/assistant/:conversationId, so the iPhone back gesture returns to the list.

It can read folders, locations, visits, photo details, trips, and kit. It does not see Trash. Folder membership is strict: when two folders share a name, it asks which one is meant.

It may fetch a missing or stale forecast through the normal forecast service: weather, the specialist readings for that location, sunset colour, and the terrain line. A reply may fetch at most ASSISTANT_MAX_FORECAST_FETCHES (12). A stored forecast under an hour old is reused and does not count. It names any location it did not check. Those fetches write only to the forecast cache and the horizon profile (forecast_writes in assistant/general_tools.py).

Where a location has the Mist & Fog subcategory, it uses that reading. For any other location it may reason from visibility, humidity, dew point, wind, and cloud around dawn, and it labels that reasoning as an assumption.

It may look at 480 px thumbnails, up to ASSISTANT_MAX_PHOTOS (6) in a reply. It comments on composition only for a thumbnail it has actually seen. A photo that is still only on the phone is reported as not on the server yet.

Use my location is off until the photographer turns it on, and it turns off again after that message is sent. The phone's position is stored on that message and synced, and the bubble says that a location was shared, with the accuracy. Retry uses the same position. The control is hidden when the page is not a secure context or the browser has no geolocation. If the position cannot be read, the draft stays and nothing is sent.

Tools, in order (assistant/general_tools.py): get_account_overview, find_locations, get_location_details, view_photos, get_forecasts, compare_locations, get_trip, get_trip_kit, get_user_kit. Catalogue tools run read-only. get_forecasts is the one that may write the weather cache. Location search matches viewpoint names, and location details list each viewpoint's name and shooting position. The assistant does not edit the catalogue and does not fetch a forecast per viewpoint.

Deleting a conversation moves it to Trash for 30 days, with its messages. Deleting one while a reply is running asks that reply to stop, and the reply is not added to a conversation that is already in Trash. The turn keeps its usage figures.

Sending a message

The browser posts the message, receives a turn id, and polls about twice a second (static/tempest/js/ui/assistantchat.js). The web process does not call Cursor. The assistant container runs python manage.py run_assistant, claims the turn from PostgreSQL, and writes the growing reply. Leaving the app or locking the phone does not cancel it. Stop does. Retry sends the same question again after an error, a stop, or an interruption.

A message is at most ASSISTANT_MAX_MESSAGE_CHARS (4000). There is no daily cap unless an admin sets user_daily_limit or global_daily_limit on the assistant settings row. One reply runs at a time for each person, across both assistants. Notes, kit names, and kit notes written by the photographer are data for the assistant, not instructions. Each reply shows the model that wrote it. When an admin changes the model, later replies use it and a divider marks the change.

Conversations and messages travel in the normal sync pull, so every signed-in device has the history offline. A device may push delete or restore for a conversation. Any other conversation write is rejected, and messages are not a push entity (syncapi/engine.py). Offline, the history is still there and the composer says that a new question needs a connection. Admins see counts, token totals, and error codes. They do not see the message text.

Worker

CURSOR_API is read by the worker. The SDK also accepts CURSOR_API_KEY; Tempest reads CURSOR_API first. Leave it empty and the worker stays up but cannot call Cursor. The key stays out of the browser, a log, a command line, and a page. The process runs one reply at a time: it claims the oldest queued turn, finishes it, then claims the next (assistant/management/commands/run_assistant.py).

ASSISTANT_DATA_DIR defaults to /data/assistant. Compose mounts data/<env>/assistant there. That directory holds the workspaces and the bridge state used to resume a conversation. Docker creates a missing host folder as root. The worker runs as uid 1000 and cannot write it until the directory is owned by uid 1000.

The general assistant uses workspace-general, so its rule stays separate from the trip assistant's rule in workspace. The worker writes .cursor/rules/assistant.mdc into the workspace it launches (assistant/prompt.py, assistant/cursor_adapter.py) and passes setting_sources=["project"]. The bridge launches from the trip workspace; LocalAgentOptions(cwd=...) is what picks up each agent's own rule.

Custom tools are offered through the SDK's built-in mcp tool. tools=[] hides them. The worker denies every other built-in and still allows mcp, getMcpTools, listMcpResources, readMcpResource, and mcpAuth. A tool call whose name is not one of those is cancelled with tool_policy. Denying mcp alone, without those four helper names, makes the run fail before the tool is called. The constants are in assistant/cursor_adapter.py and assistant/sdk_findings.py.

A custom tool can return an image. The shape that reaches the model is {"content": [{"type": "text", "text": "..."}, {"type": "image", "data": "<base64>", "mimeType": "image/jpeg"}]}. view_photos uses this shape. It is offered for Composer 2.5, Grok 4.7, Claude Sonnet claude-sonnet-5-5, and any model id that starts with claude-sonnet (sees_images in assistant/cursor_adapter.py).

Tool callbacks run on a bridge thread, not the Django thread, so each call closes its database connection when it finishes. Local agent ids from this SDK are bare UUIDs. get_usage rejects them, so cost is left empty and token counts come from the run result. Cost for those runs is on the Cursor dashboard. Resume in the state root keeps the conversation, including after a model change.

The worker refreshes the model catalogue on start, every six hours, and when an admin taps Check models now. Privacy Mode follows the Cursor account. After a change to the key, the tools, or requirements.txt, rebuild the image and recreate the web and assistant containers so both load it. Recreating a container from the old image drops a key or package that was only installed inside the running container. Commands are in workflow.md and deploy.md.

Where a change lands

Change Place
Trip tools assistant/tools.py
General tools, forecast and photo caps assistant/general_tools.py, ASSISTANT_MAX_* in tempest/settings/base.py
Send, stop, retry, one reply at a time assistant/turns.py
Worker loop and error codes assistant/runner.py
SDK session, denied tools, workspaces assistant/cursor_adapter.py, assistant/sdk_findings.py
System prompt assistant/prompt.py
Conversations, messages, model, daily caps assistant/models.py
Pull, and delete or restore from a device syncapi/engine.py
Trip panel and general page static/tempest/js/ui/assistantchat.js, assistantpage.js, ui/trip.js
Feature switches console/features.py