Developer quickstart

Build on the Energy Coach

A conversational energy advisor, grounded in your customer's own consumption data. Three calls get you a working implementation. This page covers those three, then everything you add next.

Base URL
https://<your-eliq-host>/v3/locations/{location_id}/energy-coach
Scope
insights · insights.read
Auth
Bearer token, per end customer
Getting started

Three calls to a working Coach

Build the landing screen, open a conversation, ask a question. Everything on this page after these three is polish.

Before you start

You need an access token for the end customer and a location_id they have access to. The Coach is scoped to a location, not a user. The location_id goes in the path of every call, never in the body or query string. The customer also needs consumption history: with no usable data the landing screen comes back empty and you fall back to chat only.

Tokens, scopes and the host for your environment are covered in the Insights API reference. The Coach uses the same credentials as the rest of Insights, so if you are already calling Insights you have everything you need.

Cards, answers and suggestions come back in the language the location is set to. You can override that per request with language, for example when your app has its own language picker. Currency and number formatting stay with the location's market either way.

01

Build the landing screen

Up to six conversation starters, ranked, already written in the customer's language.

POST https://<your-eliq-host>/v3/locations/4711/energy-coach/spotlights
Authorization: Bearer <access-token>
Content-Type: application/json
 
{}

Every call takes the same two headers. They are left out of the examples below to keep them short.

{
  "customer_name": "Anna",
  "coverage": { "coverage_description": "6 months (since November 2025)", … },
  "total_candidates": 47,
  "spotlights": [
    {
      "kind": "peak_hour",
      "tone": "bad",
      "topic": "Your most expensive hour",
      "headline": "5–6pm is costing you 340 kr a month.",
      "body": "Your usage peaks exactly when electricity is priciest…",
      "chat_prompt": "Why are my evenings so expensive?",
      "relevance": 82, "impact": 74, "recency": 95, "summary": 81
    }
  ]
}
  • Render the cards in the order you receive them. They are already ranked; re-sorting throws away the ranking.
  • chat_prompt is the seed question. On tap, send it as the customer's first message.
  • summary is a score, not text. It is the weighted rank, 0.45×impact + 0.30×relevance + 0.25×recency. Never render it.
  • total_candidates is the breadth of the analysis, not the card count. Good for a "top 6 of 47" line.
  • coverage.coverage_description is a ready-made string for your "based on N months" disclosure.
The 24 values of kind

capacity_tariff · category_change · cold_heating · daily_recap · ev_charging · fallback · forecast_next_month · month_so_far · monthly_report · peak_hour · peer_gap · price_spread · profile_optional · profile_required · pv_monthly · recent_anomaly · recommendations · solar · solar_good_day · spot_vs_flat · standby · standout_hour · streak · typical_weekday

Map these to icons, and give the map a default. New kinds are added as the Coach learns to spot new things, without a client release.

Empty is not an error

An empty spotlights array means the customer has no eligible data yet. Show the chat on its own.

02

Open a conversation

One id, carried in the path of everything that follows.

POST /v3/locations/4711/energy-coach/conversations
 
→ 201 { "conversation_id": "8f14e45f-ea0e-4b9c-9f2a-2b1c7d3e4f50" }

Cheap by design: no model call, no database row. The row is written by the first question, so a landing screen nobody talks to leaves nothing behind.

Two conversations can be open at once. Nothing on the server tracks a "current" one, so the id in your path is the only thing that decides which conversation a request belongs to.

Why this is its own call

The id is bound to your client and that location when it is minted, and asking against an id the server does not recognise is a 404. If asking without an id quietly started a new conversation, any client-side null would wipe the customer's history mid-chat, and they would see the Coach forget everything they had just told it.

03

Ask

Prose, follow-up chips, and sometimes a chart.

POST /v3/locations/4711/energy-coach/conversations/{id}/ask
 
{ "question": "Why was January so expensive?" }
{
  "answer": "January was 18% colder than December, and…",
  "suggestions": ["What can I do about it?", "How does February look?"],
  "widgets": [ … ]
}
  • Render suggestions as chips. They carry most of the engagement in the chat.
  • Cap question at 2000 characters client-side. Over that is a 400, and the customer loses the turn.
  • Resuming needs no special handling. Ask against a conversation from months ago and the server rebuilds it from storage. There is no resume call.
Do not auto-retry a timed-out ask

This call is not idempotent. Every request is a fresh turn, so a retry after a timeout costs a second model call and can leave the same question twice in the customer's history, with two different answers. If a request times out, surface it and let the customer decide.

The answer is not streamed

It arrives as one complete JSON body once the model has finished, so the whole turn is a single wait rather than text appearing word by word. Build a loading state into the chat and give the request a generous timeout.

Widgets

Charts that come with the answer

The prose is the answer. A widget is an optional chart attached to the same turn, for when a number is easier to see than to read.

The model does not draw the chart and does not choose its data. It signals that one belongs, and the platform builds it from data it already loaded for that turn. Signalling markers are stripped before the text reaches you, so the chart is always consistent with the words above it and you never parse anything out of answer.

The three rules that shape your UI

0 or 1

One at most, usually none

The Coach attaches at most one widget per reply, and two only when the customer explicitly asks for more than one view. Three is a hard ceiling in the platform, not something to design for. Lay the chat out for zero and one.

?

Ignore types you do not render

Switch on type and let the default branch render nothing. New kinds are added without a client release, so an older build of your app should skip one it does not recognise rather than break the message.

+

New fields will appear

Tolerate extras. A breaking change ships as a new kind, such as solar_monthly_v2, never as a changed one.

Titles come as translation keys

Each widget carries title_key and title_params, and often subtitle_key and subtitle_params, namespaced under energy_coach.*. Resolve them through your own i18n stack so the chart speaks your customer's language. Params can themselves be keys: months resolve via common.month_january and its siblings.

{
  "type": "hourly_profile",
  "title_key": "energy_coach.hourly_profile.title",
  "subtitle_key": "energy_coach.hourly_profile.window",
  "subtitle_params": { "days": 30 },
  "avg_kwh_by_hour": [0.12, 0.10, … /* 24 values */],
  "peak_hour": 17
}

There is also a pre-rendered English title on every widget. It is a fallback for clients that do not resolve keys yet, not the intended path.

The catalogue

Monthly arrays run oldest first, from_month (YYYY-MM) labels the first element, and money is already converted to the location's currency with a currency_symbol beside it.

solar_monthly Would solar work for me?

from_month · consumption_kwh[12] · production_kwh[12] · recommended_peak_kw · is_estimated_from_partial_data

Two series side by side. When the last flag is true, actuals were extended with forecast to reach twelve months, so show a small disclosure.

forecast What will next month's bill look like?

from_month · months_actual_kwh[≤12] · months_forecast_kwh[≤3]

Two arrays that run consecutively: forecast months continue in calendar order from the last actual one, so you compute every label from from_month. Draw the forecast bars faded or hatched in the same colour, with a separator where measurement stops. No cost figure, only kWh.

similar_homes How do I compare to similar homes?

from_month · user_kwh[] · peer_median_kwh[] · months_compared · gap_total_kwh · gap_total_pct

Positive gap means above peers. months_compared can be lower than the array length, so treat a null peer bar as "no data", never as zero.

hourly_profile When am I using the most?

avg_kwh_by_hour[24] · peak_hour

Averaged over the last 30 days. Highlight the peak bar and label the axis every six hours.

cheapest_hours When should I run the EV or the dryer?

variant · hours[] { time, end_time, duration_minutes, price_per_kwh, currency_symbol }

Despite the name these are price windows at 15-minute resolution, merged where cheap slots run together, so one entry covers anywhere from 15 to 60 minutes. Render the range, for example 18:45–19:30. end_time and duration_minutes are nullable; when absent the width is unknown, so render the start time alone rather than assuming an hour. variant is cheapest or most_expensive.

top_categories What am I using power on, and what changed?

month_label · items[≤3] { category, kwh, delta_kwh, delta_pct }

Categories are EUC keys: heating, hot_water, appliances, lighting. delta_pct is null when last month was zero or missing. Down is good.

capacity_tariff_peak When did I hit my billed peak?

month_label · billable_peak_kw · peak_rating · monthly_charge · currency_symbol · top_peaks[≤3]

peak_rating is one of Very good, Good, Average, Bad. Only appears where a capacity tariff applies.

Journey ev_offer A matched EV charging offer

pack_id · sign_up_url · provider_name · estimated_savings_per_year · currency_symbol · trial_days

A card with a call to action, attached to a journey's sign-up turn rather than to a question.

Next

Conversation history

List what the customer has asked before, read one back, continue it, or delete it.

GET /v3/locations/4711/energy-coach/conversations?page_size=20
{
  "retention_days": 365,
  "items": [{
    "conversation_id": "8f14e45f-…",
    "title": "Changing my fuse size",
    "started_at": "2026-09-14T18:22:31Z",
    "last_turn_at": "2026-09-14T18:31:04Z",
    "turn_count": 8,
    "language_code": "en-GB"
  }],
  "paging": { "page_size": 20, "continuation_token": "MTc1ODAwMDAwMDAw…" }
}
  • Render retention_days. Do not hardcode "twelve months". It comes from the same setting the deletion job reads, so your copy cannot disagree with what actually happens.
  • page_size defaults to 20 and caps at 50. Outside 1 to 50 is a 400 rather than a quietly smaller page.
  • continuation_token is opaque and absent on the last page. Send it back unchanged.
  • turn_count counts messages, so eight means four exchanges.
  • An empty list means one thing only: no conversations yet. Conversations with no turns, and ones the customer deleted, never appear.

Reading one back

GET /v3/locations/4711/energy-coach/conversations/{id}

Returns turns[] oldest first, each with turn_index, role, content, created_at, and feedback when the customer rated it. User turns are even, assistant turns odd. Capped at 500 messages, and empty turns are skipped, so turn_index is not guaranteed contiguous.

Deleting one

DELETE /v3/locations/4711/energy-coach/conversations/{id}
 
→ 204

Idempotent. Deleting one that is already gone also answers 204, so a retry after a dropped connection does not show the customer an error.

Next

Thumbs up, thumbs down

Ratings on an answer or a landing-screen card.

POST /v3/locations/4711/energy-coach/conversations/{id}/feedback
 
{ "target": "answer",
  "target_id": "1",
  "is_positive": true,
  "rated_content": "January was 18% colder than December, and…",
  "rated_question": "Why was January so expensive?",
  "comments": "missed my heat pump" }
 
→ 204
  • target says what was rated, and target_id says which one.
    For answer, it is the position of that message in the conversation, as a string. Counting starts at zero with the customer's first question, so the first answer is "1", the second is "3", and so on. It is the same turn_index you get back when you read a conversation.
    For spotlight, it is the card's kind, for example "peak_hour".
  • Always send rated_content: the reply text for an answer, or topic plus headline plus body for a card. Live conversation state is cached for two hours, so without that snapshot the rating cannot be interpreted later.
  • rated_question is answer feedback only. Leave it out for a spotlight, which has no preceding question.
Reference

Every endpoint

Seven routes. All under /v3/locations/{location_id}/energy-coach, all requiring scope insights or insights.read, all checking location access first.

MethodPathPurpose
POST/spotlightsBuild the landing screen
POST/conversationsOpen a conversation, return its id
POST/conversations/{id}/askAsk a question in it, new or months old
POST/conversations/{id}/feedbackRate an answer
GET/conversationsList the location's conversations, newest first
GET/conversations/{id}Read one in full
DELETE/conversations/{id}Delete one

Errors

Platform errors carry { code, description }. Show your own copy to the customer and keep description for your logs.

HTTPCodeWhen
400INVALID_PARAMETERMissing or malformed field, question over 2000 characters, page_size outside 1 to 50, bad paging token
404LOCATION_NOT_FOUNDNo access to that location. 404 rather than 403, so it does not confirm whether the location exists
404CONVERSATION_NOT_FOUNDMissing, deleted, another location, or another client, conflated on purpose
429QUOTA_EXCEEDEDModel capacity is momentarily exhausted. Retry with backoff
400AI_CONTENT_FILTEREDThe content filter rejected the exchange
503UNEXPECTED_AI_ERRORThe model provider failed. Retry with backoff
Before you ship

One thing that catches people

Null fields are omitted, not null

customer_name is absent when the location has no named user; continuation_token is absent on the last page; feedback is absent on an unrated turn. Code for absence.

Ship it

Integration checklist

  • Cards rendered in the order received
  • summary never printed
  • Card tap sends its chat_prompt
  • Questions capped at 2000 characters
  • Widget switch has a default branch that renders nothing
  • Chat lays out correctly with zero widgets and with three
  • Widget titles resolved from title_key
  • retention_days rendered, not hardcoded
  • Empty history shown as "no conversations yet"
  • Absent fields handled, not just null ones
  • Thumbs up and down send rated_content