diff --git a/LoopIOS/AgentHarness/AgentHarness.swift b/LoopIOS/AgentHarness/AgentHarness.swift
index fd00ef1..3ed4344 100644
--- a/LoopIOS/AgentHarness/AgentHarness.swift
+++ b/LoopIOS/AgentHarness/AgentHarness.swift
@@ -131,6 +131,7 @@ final class AgentHarness {
#if canImport(HealthKit) && os(iOS)
catalog.append(("Apple Health", "Read-only access to steps, distance, workouts, heart rate, sleep, body mass"))
#endif
+ catalog.append(("Higgsfield", "Cinematic video generation via Higgsfield Cloud (Sora 2, Veo 3, Kling, etc.)"))
return catalog
}()
@@ -164,6 +165,7 @@ final class AgentHarness {
#if canImport(HealthKit) && os(iOS)
fragments.append(HealthSkill.systemPromptFragment)
#endif
+ fragments.append(HiggsFieldSkill.systemPromptFragment)
self.toolsDoc = fragments.joined(separator: "\n\n")
self.staticToolsDocLength = toolsDoc.count
self.staticToolSchemasCount = toolSchemas.count
diff --git a/LoopIOS/AgentHarness/SkillDispatcher.swift b/LoopIOS/AgentHarness/SkillDispatcher.swift
index bb915ff..ef9c1ed 100644
--- a/LoopIOS/AgentHarness/SkillDispatcher.swift
+++ b/LoopIOS/AgentHarness/SkillDispatcher.swift
@@ -133,6 +133,9 @@ final class SkillDispatcher {
HealthSkill.shared.handle(functionCall: call, completion: completion); return
}
#endif
+ if HiggsFieldSkill.shared.handles(functionName: call.name) {
+ HiggsFieldSkill.shared.handle(functionCall: call, completion: completion); return
+ }
// Runtime-registered skills (Mac-only). Checked under the lock so a
// concurrent register() can't tear the iteration.
diff --git a/LoopIOS/Info.plist b/LoopIOS/Info.plist
index a4bb3e4..90de36e 100644
--- a/LoopIOS/Info.plist
+++ b/LoopIOS/Info.plist
@@ -37,6 +37,8 @@
$(KIMI_API_KEY)
CURSOR_API_KEY
$(CURSOR_API_KEY)
+ HIGGSFIELD_API_KEY
+ $(HIGGSFIELD_API_KEY)
OBSIDIAN_API_KEY
$(OBSIDIAN_API_KEY)
OBSIDIAN_BASE_URL
diff --git a/LoopIOS/MessagingVC.swift b/LoopIOS/MessagingVC.swift
index 4143ad5..d94f1b7 100644
--- a/LoopIOS/MessagingVC.swift
+++ b/LoopIOS/MessagingVC.swift
@@ -1070,6 +1070,7 @@ extension MessagingVC: MessageBoxDelegate {
#if canImport(HealthKit) && os(iOS)
if let s = HealthSkill.shared.statusText(for: call) { return s }
#endif
+ if let s = HiggsFieldSkill.shared.statusText(for: call) { return s }
if let s = DynamicSkillRegistry.shared.statusText(for: call) { return s }
switch call.name {
diff --git a/LoopIOS/Settings/KeyStore.swift b/LoopIOS/Settings/KeyStore.swift
index 5b3cde2..4a15a13 100644
--- a/LoopIOS/Settings/KeyStore.swift
+++ b/LoopIOS/Settings/KeyStore.swift
@@ -43,6 +43,7 @@ final class KeyStore {
case xAPISecret = "X_API_SECRET"
case xAccessToken = "X_ACCESS_TOKEN"
case xAccessTokenSecret = "X_ACCESS_TOKEN_SECRET"
+ case higgsfield = "HIGGSFIELD_API_KEY"
/// User-facing label shown in Settings.
var displayName: String {
@@ -67,6 +68,7 @@ final class KeyStore {
case .xAPISecret: return "X API Secret"
case .xAccessToken: return "X Access Token"
case .xAccessTokenSecret: return "X Access Token Secret"
+ case .higgsfield: return "Higgsfield"
}
}
@@ -93,6 +95,7 @@ final class KeyStore {
case .xAPISecret: return "OAuth 1.0a consumer secret — shown once when you regenerate the API key"
case .xAccessToken: return "User-level access token with read+write permission from developer.x.com"
case .xAccessTokenSecret: return "User-level access token secret — shown once at generation time"
+ case .higgsfield: return "KEY_ID:KEY_SECRET credentials from cloud.higgsfield.ai → API Keys. Used for cinematic video generation (Sora, Veo, Kling, etc.)."
}
}
}
@@ -104,7 +107,7 @@ final class KeyStore {
/// either add a new `Service` case here or extend an existing one's `keys`.
enum Service: String, CaseIterable {
case openAI, anthropic, kimi, deepgram, elevenLabs, exa
- case cursor, devin
+ case cursor, devin, higgsfield
case github, slack, notion, obsidian
case twitter
@@ -119,6 +122,7 @@ final class KeyStore {
case .exa: return "Exa"
case .cursor: return "Cursor"
case .devin: return "Devin"
+ case .higgsfield: return "Higgsfield"
case .github: return "GitHub"
case .slack: return "Slack"
case .notion: return "Notion"
@@ -141,6 +145,7 @@ final class KeyStore {
case .exa: return "Web search + answer skill"
case .cursor: return "Dispatch coding tasks to Cursor cloud agents (opens PRs)"
case .devin: return "Dispatch coding tasks to Devin cloud agents (opens PRs, live transcript)"
+ case .higgsfield: return "Cinematic video generation via Higgsfield Cloud (Sora 2, Veo 3, Kling, Seedance, etc.)"
case .github: return "Review/merge PRs, open PRs and issues, browse repos and notifications"
case .slack: return "Read channels/DMs/mentions, search, and send messages with confirmation"
case .notion: return "Read and write Notion pages and databases"
@@ -164,6 +169,7 @@ final class KeyStore {
case .exa: return [.exa]
case .cursor: return [.cursor]
case .devin: return [.devin, .devinOrgID]
+ case .higgsfield: return [.higgsfield]
case .github: return [.githubPAT, .githubBaseURL]
case .slack: return [.slackUserToken]
case .notion: return [.notionIntegrationToken]
diff --git a/LoopIOS/Skills/Dynamic/BundledDocSeeds.swift b/LoopIOS/Skills/Dynamic/BundledDocSeeds.swift
index 867cc02..fdadbd2 100644
--- a/LoopIOS/Skills/Dynamic/BundledDocSeeds.swift
+++ b/LoopIOS/Skills/Dynamic/BundledDocSeeds.swift
@@ -111,7 +111,8 @@ skill follows the same shape:
Bundled skills include: Notion, Scheduler, Web search (Exa), URL fetch,
Self-Improvement, File System, Spec Builder, Location, Image, Obsidian,
-Calendar, Music, Skill Builder, Sub-Agent, and Integrations. The user (or the
+Calendar, Music, Skill Builder, Sub-Agent, Integrations, Cursor, Devin, and
+Higgsfield (cinematic video generation). The user (or the
agent) can also author new skills in JavaScript at runtime — those hot-load
from `Workspace/Skills/` and don't require a rebuild.
diff --git a/LoopIOS/Skills/Higgsfield/HiggsFieldSkill.swift b/LoopIOS/Skills/Higgsfield/HiggsFieldSkill.swift
new file mode 100644
index 0000000..5c88869
--- /dev/null
+++ b/LoopIOS/Skills/Higgsfield/HiggsFieldSkill.swift
@@ -0,0 +1,425 @@
+//
+// HiggsFieldSkill.swift
+// Loop
+//
+// Lets Loop generate cinematic videos through the Higgsfield Cloud API
+// (https://cloud.higgsfield.ai). Calls the REST API directly from the
+// device using the HIGGSFIELD_API_KEY stored in the Keychain, so this
+// skill does not depend on the backend.
+//
+// The Higgsfield Cloud API is asynchronous — generation requests return a
+// request_id immediately, and the caller polls for completion. This skill
+// surfaces three tools:
+// - higgsfield_generate_video: kick off a video generation job.
+// - higgsfield_check_video: poll a running job's status.
+// - higgsfield_list_models: enumerate available models and presets.
+//
+
+import Foundation
+
+struct HiggsFieldSkill {
+ static let shared = HiggsFieldSkill()
+
+ private static let baseURL = "https://platform.higgsfield.ai"
+
+ // MARK: - System prompt
+
+ static let systemPromptFragment: String = """
+You can generate cinematic videos through Higgsfield Cloud with these tools:
+- higgsfield_generate_video: start a video generation job. Pass a `prompt` (required), plus optional `model` (endpoint slug — see higgsfield_list_models), `aspect_ratio` (e.g. "16:9", "9:16", "1:1"), `reference_image_url`, and `duration_seconds`. Returns a `request_id` to poll.
+- higgsfield_check_video: check the status of a running job. Pass `request_id`. Returns `{status, video_url?, thumbnail_url?, error?}`. Status is one of: queued, in_progress, completed, failed, nsfw.
+- higgsfield_list_models: list available Higgsfield models (Sora 2, Veo 3, Kling, Seedance, DoP, etc.) and their endpoints.
+
+Workflow:
+1. Call higgsfield_generate_video with a prompt. You get back a request_id immediately.
+2. Poll with higgsfield_check_video every few seconds until status is "completed" or terminal ("failed"/"nsfw").
+3. When completed, the response includes video_url (and thumbnail_url for images).
+
+Notes:
+- The user must set HIGGSFIELD_API_KEY in Settings → Keys first (format: KEY_ID:KEY_SECRET from cloud.higgsfield.ai → API Keys).
+- Pricing is credit-based on Higgsfield Cloud. Each generation deducts credits; failed/nsfw jobs are refunded.
+- If the key is missing, the tool returns an error with a hint to set it.
+"""
+
+ // MARK: - Tool schemas
+
+ static let tools: [[String: Any]] = [
+ [
+ "type": "function",
+ "function": [
+ "name": "higgsfield_generate_video",
+ "description": "Start a video generation job on Higgsfield Cloud. Returns a request_id to poll with higgsfield_check_video. Requires HIGGSFIELD_API_KEY.",
+ "parameters": [
+ "type": "object",
+ "properties": [
+ "prompt": [
+ "type": "string",
+ "description": "Descriptive prompt for the video to generate."
+ ],
+ "model": [
+ "type": "string",
+ "description": "Model endpoint slug (e.g. \"wan/i2v\", \"kling/v2.1/pro/image-to-video\", \"veo/3\"). Defaults to \"wan/i2v\". Use higgsfield_list_models to see all options."
+ ],
+ "aspect_ratio": [
+ "type": "string",
+ "description": "Aspect ratio, e.g. \"16:9\", \"9:16\", \"1:1\". Defaults to \"16:9\"."
+ ],
+ "reference_image_url": [
+ "type": "string",
+ "description": "Optional URL of a reference image for image-to-video generation."
+ ],
+ "duration_seconds": [
+ "type": "integer",
+ "description": "Desired duration in seconds. Availability depends on the model."
+ ]
+ ],
+ "required": ["prompt"]
+ ]
+ ]
+ ],
+ [
+ "type": "function",
+ "function": [
+ "name": "higgsfield_check_video",
+ "description": "Check the status of a Higgsfield video generation job. Returns status (queued/in_progress/completed/failed/nsfw), video_url, thumbnail_url, or error.",
+ "parameters": [
+ "type": "object",
+ "properties": [
+ "request_id": [
+ "type": "string",
+ "description": "The request_id returned by higgsfield_generate_video."
+ ]
+ ],
+ "required": ["request_id"]
+ ]
+ ]
+ ],
+ [
+ "type": "function",
+ "function": [
+ "name": "higgsfield_list_models",
+ "description": "List available Higgsfield Cloud video/image generation models, their endpoint slugs, and capabilities.",
+ "parameters": [
+ "type": "object",
+ "properties": [:],
+ "required": []
+ ]
+ ]
+ ]
+ ]
+
+ static let toolNames: Set = [
+ "higgsfield_generate_video",
+ "higgsfield_check_video",
+ "higgsfield_list_models"
+ ]
+
+ func handles(functionName: String) -> Bool {
+ return HiggsFieldSkill.toolNames.contains(functionName)
+ }
+
+ func statusText(for call: FunctionCallStruct) -> String? {
+ switch call.name {
+ case "higgsfield_generate_video":
+ if let p = (call.arguments["prompt"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !p.isEmpty {
+ let short = p.count > 40 ? String(p.prefix(40)) + "…" : p
+ return "generating video: \(short)"
+ }
+ return "generating video"
+ case "higgsfield_check_video":
+ return "checking video status"
+ case "higgsfield_list_models":
+ return "listing Higgsfield models"
+ default:
+ return nil
+ }
+ }
+
+ // MARK: - Dispatch
+
+ func handle(functionCall: FunctionCallStruct,
+ completion: @escaping (MessageStruct) -> Void) {
+ let args = functionCall.arguments
+ switch functionCall.name {
+ case "higgsfield_generate_video":
+ guard let prompt = args["prompt"] as? String, !prompt.isEmpty else {
+ completion(missingArgs(for: "higgsfield_generate_video", expected: "prompt"))
+ return
+ }
+ let model = (args["model"] as? String) ?? "wan/i2v"
+ let aspectRatio = (args["aspect_ratio"] as? String) ?? "16:9"
+ let refImage = args["reference_image_url"] as? String
+ let duration = intArg(args["duration_seconds"])
+ generateVideo(prompt: prompt,
+ model: model,
+ aspectRatio: aspectRatio,
+ referenceImageURL: refImage,
+ durationSeconds: duration,
+ completion: completion)
+
+ case "higgsfield_check_video":
+ guard let requestId = args["request_id"] as? String, !requestId.isEmpty else {
+ completion(missingArgs(for: "higgsfield_check_video", expected: "request_id"))
+ return
+ }
+ checkVideo(requestId: requestId, completion: completion)
+
+ case "higgsfield_list_models":
+ listModels(completion: completion)
+
+ default:
+ completion(MessageStruct(
+ role: "assistant",
+ content: "I don't know how to handle the Higgsfield tool '\(functionCall.name)'."
+ ))
+ }
+ }
+
+ // MARK: - Tool handlers
+
+ private func generateVideo(prompt: String,
+ model: String,
+ aspectRatio: String,
+ referenceImageURL: String?,
+ durationSeconds: Int?,
+ completion: @escaping (MessageStruct) -> Void) {
+ guard let creds = HiggsFieldSkill.credentials else {
+ completion(HiggsFieldSkill.notConnectedMessage(for: "higgsfield_generate_video"))
+ return
+ }
+
+ // Build endpoint — the v2 API takes the model slug as the path.
+ let endpoint = model.hasPrefix("/") ? model : "/\(model)"
+
+ // Build request body matching the v2 API format (input fields at
+ // the top level of the JSON body).
+ var body: [String: Any] = [
+ "prompt": prompt,
+ "aspect_ratio": aspectRatio
+ ]
+ if let ref = referenceImageURL, !ref.isEmpty {
+ body["input_images"] = [
+ ["type": "image_url", "image_url": ref]
+ ]
+ }
+ if let dur = durationSeconds {
+ body["duration"] = dur
+ }
+
+ post(path: endpoint, credentials: creds, body: body) { json, error in
+ guard let json = json else {
+ completion(HiggsFieldSkill.errorMessage(
+ "Failed to start Higgsfield video generation.",
+ error: error,
+ toolName: "higgsfield_generate_video"))
+ return
+ }
+ // The API returns { request_id, status, status_url, cancel_url }
+ let requestId = (json["request_id"] as? String) ?? ""
+ let status = (json["status"] as? String) ?? "queued"
+ let statusURL = (json["status_url"] as? String) ?? ""
+
+ var payload: [String: Any] = [
+ "request_id": requestId,
+ "status": status
+ ]
+ if !statusURL.isEmpty { payload["status_url"] = statusURL }
+
+ completion(HiggsFieldSkill.functionMessage(
+ name: "higgsfield_generate_video", payload: payload))
+ }
+ }
+
+ private func checkVideo(requestId: String,
+ completion: @escaping (MessageStruct) -> Void) {
+ guard let creds = HiggsFieldSkill.credentials else {
+ completion(HiggsFieldSkill.notConnectedMessage(for: "higgsfield_check_video"))
+ return
+ }
+
+ let path = "/requests/\(requestId)/status"
+ get(path: path, credentials: creds) { json, error in
+ guard let json = json else {
+ completion(HiggsFieldSkill.errorMessage(
+ "Failed to check Higgsfield job status.",
+ error: error,
+ toolName: "higgsfield_check_video"))
+ return
+ }
+ let status = (json["status"] as? String) ?? "unknown"
+ var payload: [String: Any] = [
+ "request_id": requestId,
+ "status": status
+ ]
+ // Completed jobs include media URLs.
+ if let video = json["video"] as? [String: Any],
+ let url = video["url"] as? String {
+ payload["video_url"] = url
+ }
+ if let images = json["images"] as? [[String: Any]],
+ let first = images.first,
+ let url = first["url"] as? String {
+ payload["thumbnail_url"] = url
+ }
+ // Failed jobs may include an error message.
+ if status == "failed" || status == "nsfw" {
+ let errMsg = (json["error"] as? String)
+ ?? (json["message"] as? String)
+ if let e = errMsg { payload["error"] = e }
+ }
+ completion(HiggsFieldSkill.functionMessage(
+ name: "higgsfield_check_video", payload: payload))
+ }
+ }
+
+ private func listModels(completion: @escaping (MessageStruct) -> Void) {
+ // The Higgsfield Cloud platform exposes many models through
+ // endpoint-based routing. This is a curated catalog of the
+ // publicly documented endpoints — kept static so the tool
+ // works even without network access and doesn't require an
+ // API key.
+ let models: [[String: Any]] = [
+ ["name": "Wan I2V", "endpoint": "wan/i2v", "type": "image-to-video", "notes": "Wan model — fast image-to-video generation"],
+ ["name": "Wan T2V", "endpoint": "wan/t2v", "type": "text-to-video", "notes": "Wan model — text-to-video generation"],
+ ["name": "Kling 2.1 Pro", "endpoint": "kling/v2.1/pro/image-to-video", "type": "image-to-video", "notes": "Kling v2.1 Pro — high-quality cinematic video"],
+ ["name": "Kling 2.1 Std", "endpoint": "kling/v2.1/standard/image-to-video", "type": "image-to-video", "notes": "Kling v2.1 Standard — faster, lower cost"],
+ ["name": "Veo 3", "endpoint": "veo/3", "type": "text-to-video", "notes": "Google Veo 3 — high-fidelity video generation"],
+ ["name": "Seedance 1.0", "endpoint": "seedance/v1/image-to-video", "type": "image-to-video", "notes": "ByteDance Seedance — dance/motion-focused video"],
+ ["name": "Sora 2", "endpoint": "sora/v2", "type": "text-to-video", "notes": "OpenAI Sora 2 — cinematic text-to-video"],
+ ["name": "DoP Turbo", "endpoint": "v1/image2video/dop", "type": "image-to-video", "notes": "Higgsfield DoP Turbo — fast cinematic camera movement"],
+ ["name": "Flux Kontext", "endpoint": "flux-pro/kontext/max/text-to-image", "type": "text-to-image", "notes": "Flux Pro Kontext — high-quality text-to-image"],
+ ["name": "Soul T2I", "endpoint": "v1/text2image/soul", "type": "text-to-image", "notes": "Higgsfield Soul — stylized text-to-image"],
+ ]
+ completion(HiggsFieldSkill.functionMessage(
+ name: "higgsfield_list_models",
+ payload: ["models": models]))
+ }
+
+ // MARK: - HTTP
+
+ /// Parsed KEY_ID:KEY_SECRET pair.
+ private struct Credentials {
+ let keyID: String
+ let keySecret: String
+ var authHeader: String { "Key \(keyID):\(keySecret)" }
+ }
+
+ private static var credentials: Credentials? {
+ guard let raw = KeyStore.shared.value(for: .higgsfield), !raw.isEmpty else {
+ return nil
+ }
+ let parts = raw.split(separator: ":", maxSplits: 1)
+ guard parts.count == 2 else { return nil }
+ return Credentials(keyID: String(parts[0]), keySecret: String(parts[1]))
+ }
+
+ private func post(path: String,
+ credentials: Credentials,
+ body: [String: Any],
+ completion: @escaping ([String: Any]?, Error?) -> Void) {
+ guard let url = URL(string: HiggsFieldSkill.baseURL + path) else {
+ completion(nil, NSError(domain: "HiggsFieldSkill", code: -2,
+ userInfo: [NSLocalizedDescriptionKey: "Bad Higgsfield URL"]))
+ return
+ }
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.setValue(credentials.authHeader, forHTTPHeaderField: "Authorization")
+ request.httpBody = try? JSONSerialization.data(withJSONObject: body, options: [])
+
+ URLSession.shared.dataTask(with: request) { data, response, error in
+ HiggsFieldSkill.parse(data: data, response: response, error: error, completion: completion)
+ }.resume()
+ }
+
+ private func get(path: String,
+ credentials: Credentials,
+ completion: @escaping ([String: Any]?, Error?) -> Void) {
+ guard let url = URL(string: HiggsFieldSkill.baseURL + path) else {
+ completion(nil, NSError(domain: "HiggsFieldSkill", code: -2,
+ userInfo: [NSLocalizedDescriptionKey: "Bad Higgsfield URL"]))
+ return
+ }
+ var request = URLRequest(url: url)
+ request.httpMethod = "GET"
+ request.setValue(credentials.authHeader, forHTTPHeaderField: "Authorization")
+
+ URLSession.shared.dataTask(with: request) { data, response, error in
+ HiggsFieldSkill.parse(data: data, response: response, error: error, completion: completion)
+ }.resume()
+ }
+
+ private static func parse(data: Data?,
+ response: URLResponse?,
+ error: Error?,
+ completion: @escaping ([String: Any]?, Error?) -> Void) {
+ if let error = error {
+ completion(nil, error)
+ return
+ }
+ guard let data = data else {
+ completion(nil, NSError(domain: "HiggsFieldSkill", code: -3,
+ userInfo: [NSLocalizedDescriptionKey: "Empty Higgsfield response"]))
+ return
+ }
+ let status = (response as? HTTPURLResponse)?.statusCode ?? 0
+ guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ let snippet = String(data: data.prefix(200), encoding: .utf8) ?? ""
+ completion(nil, NSError(domain: "HiggsFieldSkill", code: status,
+ userInfo: [NSLocalizedDescriptionKey: "Higgsfield returned non-JSON (status \(status)): \(snippet)"]))
+ return
+ }
+ if status >= 400 {
+ let msg = (json["error"] as? String)
+ ?? (json["detail"] as? String)
+ ?? (json["message"] as? String)
+ ?? "Higgsfield request failed (status \(status))"
+ completion(nil, NSError(domain: "HiggsFieldSkill", code: status,
+ userInfo: [NSLocalizedDescriptionKey: msg]))
+ return
+ }
+ completion(json, nil)
+ }
+
+ // MARK: - Helpers
+
+ private func intArg(_ value: Any?) -> Int? {
+ if let i = value as? Int { return i }
+ if let d = value as? Double { return Int(d) }
+ if let s = value as? String, let i = Int(s) { return i }
+ return nil
+ }
+
+ private func missingArgs(for name: String, expected: String) -> MessageStruct {
+ return MessageStruct(role: "assistant",
+ content: "I need \(expected) to call \(name). Please provide them.")
+ }
+
+ private static func notConnectedMessage(for toolName: String) -> MessageStruct {
+ let payload: [String: Any] = [
+ "error": "higgsfield_not_connected",
+ "hint": "Set your Higgsfield API key (KEY_ID:KEY_SECRET) in Settings → Keys → Higgsfield. Get one at cloud.higgsfield.ai → API Keys."
+ ]
+ return functionMessage(name: toolName, payload: payload)
+ }
+
+ private static func errorMessage(_ fallback: String,
+ error: Error?,
+ toolName: String) -> MessageStruct {
+ let msg = error?.localizedDescription ?? fallback
+ return functionMessage(name: toolName, payload: ["error": msg])
+ }
+
+ private static func functionMessage(name: String, payload: Any) -> MessageStruct {
+ let json: String
+ if let data = try? JSONSerialization.data(withJSONObject: payload, options: []),
+ let str = String(data: data, encoding: .utf8) {
+ json = str
+ } else {
+ json = "{}"
+ }
+ return MessageStruct(role: "function", content: json, name: name)
+ }
+}
diff --git a/LoopIOS/Skills/Integrations/IntegrationSkill.swift b/LoopIOS/Skills/Integrations/IntegrationSkill.swift
index 5ffddd9..fd092e6 100644
--- a/LoopIOS/Skills/Integrations/IntegrationSkill.swift
+++ b/LoopIOS/Skills/Integrations/IntegrationSkill.swift
@@ -51,7 +51,7 @@ You can manage the user's integrations and API keys directly:
- list_integrations: enumerate the integrations Loop knows about (Google Calendar via EventKit, Notion, Gmail, Slack, Apple Health) and their current connection state. Notion and Slack are token-backed (ntn_… integration token and xoxp- user token respectively, both stored in the Keychain) — status flips to "connected" once the user pastes the relevant key. Apple Health is OS-permission-backed (like Calendar).
- connect_integration: kick off the connect flow for a named integration. For Google Calendar this triggers the OS permission prompt when status is undetermined; if access was previously denied, the tool returns a hint telling you to call open_integration_settings with target="calendar_privacy". For Slack, this returns a `needs_api_key` payload with instructions to walk the user through minting an xoxp- token and pasting it in Settings → Keys → Slack User Token.
- open_integration_settings: surfaces the in-app Integrations panel (target="in_app", default) or the system Privacy pane (target="calendar_privacy"). Use this when the user says "open integrations" / "let me see my settings".
-- list_api_keys: reports which API keys are currently set (Deepgram, ElevenLabs, OpenAI, Exa, Cursor, Obsidian). Values are never returned — only whether each is present.
+- list_api_keys: reports which API keys are currently set (Deepgram, ElevenLabs, OpenAI, Exa, Cursor, Obsidian, Higgsfield, etc.). Values are never returned — only whether each is present.
- set_api_key: store a value in the keychain. Accepts either the canonical name ("OPENAI_API_KEY") or a friendly alias ("openai"). IMPORTANT: voice transcription is lossy — always read the value back to the user character-by-character and confirm before calling this tool. If the user typed the value, you can call it directly.
Tips:
@@ -85,7 +85,7 @@ Tips:
"properties": [
"name": [
"type": "string",
- "description": "Integration identifier. One of: calendar, notion, gmail, slack, health."
+ "description": "Integration identifier. One of: calendar, notion, gmail, slack, health, higgsfield."
]
],
"required": ["name"]
@@ -135,7 +135,7 @@ Tips:
"properties": [
"name": [
"type": "string",
- "description": "Key identifier. Aliases accepted: openai, deepgram, elevenlabs, exa, cursor, obsidian_api, obsidian_base_url, obsidian_vault_name."
+ "description": "Key identifier. Aliases accepted: openai, deepgram, elevenlabs, exa, cursor, obsidian_api, obsidian_base_url, obsidian_vault_name, higgsfield."
],
"value": [
"type": "string",
@@ -255,6 +255,15 @@ Tips:
? "Paste a personal xoxp- token in Settings → Keys → Slack User Token. The user can mint one at api.slack.com/apps with the scopes listed in Specs/3. Integrations Spec.md."
: "Connected via personal user token. Slack tools are live."
]
+ let hfKey = KeyStore.shared.value(for: .higgsfield) ?? ""
+ let higgsfield: [String: Any] = [
+ "name": "higgsfield",
+ "display_name": "Higgsfield",
+ "status": hfKey.isEmpty ? "not_connected" : "connected",
+ "hint": hfKey.isEmpty
+ ? "Paste KEY_ID:KEY_SECRET credentials from cloud.higgsfield.ai → API Keys in Settings → Keys → Higgsfield."
+ : "Connected via API key. Higgsfield video generation tools are live."
+ ]
#if canImport(HealthKit) && os(iOS)
let healthStatus = HealthKitManager.shared.currentAuthorizationStatus
let health: [String: Any] = [
@@ -263,7 +272,7 @@ Tips:
"status": healthStatus.rawValue,
"hint": healthHint(healthStatus)
]
- return [calendar, notion, gmail, slack, health]
+ return [calendar, notion, gmail, slack, health, higgsfield]
#else
let health: [String: Any] = [
"name": "health",
@@ -271,7 +280,7 @@ Tips:
"status": "unavailable",
"hint": "HealthKit is not available on this platform."
]
- return [calendar, notion, gmail, slack, health]
+ return [calendar, notion, gmail, slack, health, higgsfield]
#endif
}
@@ -424,11 +433,27 @@ Tips:
]))
#endif
+ case "higgsfield":
+ let key = KeyStore.shared.value(for: .higgsfield) ?? ""
+ if !key.isEmpty {
+ completion(Self.functionMessage(name: toolName, payload: [
+ "name": "higgsfield",
+ "status": "already_connected",
+ "message": "Higgsfield is already connected via API key. Video generation tools are live."
+ ]))
+ } else {
+ completion(Self.functionMessage(name: toolName, payload: [
+ "name": "higgsfield",
+ "status": "needs_api_key",
+ "next_action": "Tell the user to paste their Higgsfield credentials (KEY_ID:KEY_SECRET format) in Settings \u{2192} Keys \u{2192} Higgsfield. They can create API keys at cloud.higgsfield.ai \u{2192} API Keys."
+ ]))
+ }
+
default:
completion(Self.functionMessage(name: toolName, payload: [
"error": "unknown_integration",
"name": which,
- "hint": "Known integrations: calendar, notion, gmail, slack, health."
+ "hint": "Known integrations: calendar, notion, gmail, slack, health, higgsfield."
]))
}
}
@@ -506,6 +531,7 @@ Tips:
case .xAPISecret: return "x_api_secret"
case .xAccessToken: return "x_access_token"
case .xAccessTokenSecret: return "x_access_token_secret"
+ case .higgsfield: return "higgsfield"
}
}
@@ -519,7 +545,7 @@ Tips:
completion(Self.functionMessage(name: toolName, payload: [
"error": "unknown_key",
"name": name,
- "hint": "Aliases accepted: openai, deepgram, elevenlabs, exa, cursor, obsidian_api, obsidian_base_url, obsidian_vault_name."
+ "hint": "Aliases accepted: openai, deepgram, elevenlabs, exa, cursor, obsidian_api, obsidian_base_url, obsidian_vault_name, higgsfield."
]))
return
}
@@ -566,6 +592,7 @@ Tips:
case "x_access_token", "twitter_access_token": return .xAccessToken
case "x_access_token_secret",
"twitter_access_token_secret": return .xAccessTokenSecret
+ case "higgsfield", "higgsfield_api_key": return .higgsfield
default: return nil
}
}
diff --git a/LoopIOS/Structs/Messaging.swift b/LoopIOS/Structs/Messaging.swift
index 3c54be1..aec76e6 100644
--- a/LoopIOS/Structs/Messaging.swift
+++ b/LoopIOS/Structs/Messaging.swift
@@ -486,6 +486,7 @@ var tools: [[String: Any]] = {
#if canImport(HealthKit) && os(iOS)
all += HealthSkill.tools
#endif
+ all += HiggsFieldSkill.tools
// Dynamic, user-authored skills get appended in AgentHarness at every
// chat turn so newly hot-loaded skills become visible without restart.
return all
diff --git a/LoopMac/Info.plist b/LoopMac/Info.plist
index 65a469f..54aebd2 100644
--- a/LoopMac/Info.plist
+++ b/LoopMac/Info.plist
@@ -43,6 +43,8 @@
$(ANTHROPIC_API_KEY)
KIMI_API_KEY
$(KIMI_API_KEY)
+ HIGGSFIELD_API_KEY
+ $(HIGGSFIELD_API_KEY)
OBSIDIAN_API_KEY
$(OBSIDIAN_API_KEY)
OBSIDIAN_BASE_URL
diff --git a/LoopMac/VoiceLoopCoordinator.swift b/LoopMac/VoiceLoopCoordinator.swift
index 140a471..e6b43d1 100644
--- a/LoopMac/VoiceLoopCoordinator.swift
+++ b/LoopMac/VoiceLoopCoordinator.swift
@@ -850,6 +850,9 @@ The current date and time is \(now).
if CursorSkill.shared.handles(functionName: function.name) {
CursorSkill.shared.handle(functionCall: function, completion: cont); return
}
+ if HiggsFieldSkill.shared.handles(functionName: function.name) {
+ HiggsFieldSkill.shared.handle(functionCall: function, completion: cont); return
+ }
// Dynamic (user-authored JS) skills last — hot-loaded so the
// registry is the source of truth for what's currently available.
if DynamicSkillRegistry.shared.handles(functionName: function.name) {
@@ -879,6 +882,7 @@ The current date and time is \(now).
if let s = SubAgentSkill.shared.statusText(for: call) { return s }
if let s = DevinSkill.shared.statusText(for: call) { return s }
if let s = CursorSkill.shared.statusText(for: call) { return s }
+ if let s = HiggsFieldSkill.shared.statusText(for: call) { return s }
if let s = DynamicSkillRegistry.shared.statusText(for: call) { return s }
return "running \(call.name.replacingOccurrences(of: "_", with: " "))"
}
diff --git a/README.md b/README.md
index d15fd69..67d6988 100644
--- a/README.md
+++ b/README.md
@@ -85,6 +85,34 @@ security policy. Secret scanning runs in CI on every push
(`.github/workflows/secret-scan.yml`). To report a vulnerability, see
`SECURITY.md`.
+## Integrations
+
+### Higgsfield — AI video generation
+
+Loop can generate cinematic videos through the
+[Higgsfield Cloud](https://cloud.higgsfield.ai/) API. The integration
+surfaces three agent tools:
+
+| Tool | Purpose |
+|------|---------|
+| `higgsfield_generate_video` | Start a generation job (returns a `request_id` immediately). |
+| `higgsfield_check_video` | Poll a job's status; returns `video_url` when complete. |
+| `higgsfield_list_models` | List available models (Sora 2, Veo 3, Kling 2.1, Seedance, DoP, Flux, Soul). |
+
+**Setup:** paste your `KEY_ID:KEY_SECRET` credentials (from
+*cloud.higgsfield.ai → API Keys*) into **Settings → Keys → Higgsfield**.
+The key is stored in the Keychain and never leaves your device except to
+Higgsfield's servers.
+
+**Async job pattern:** generation is asynchronous — `higgsfield_generate_video`
+returns instantly with a `request_id`. The agent then polls
+`higgsfield_check_video` until the status reaches `completed` (or a terminal
+state like `failed` / `nsfw`). Pricing is credit-based on Higgsfield Cloud;
+failed or moderation-flagged jobs are automatically refunded.
+
+If the API key is not set, any Higgsfield tool call returns
+`{"error": "higgsfield_not_connected"}` with a hint to configure the key.
+
## Status
Early open-source release. APIs and structure will change. Contributions and
diff --git a/Secrets.xcconfig.example b/Secrets.xcconfig.example
index 97abe88..f9147ad 100644
--- a/Secrets.xcconfig.example
+++ b/Secrets.xcconfig.example
@@ -32,5 +32,6 @@ ANTHROPIC_API_KEY =
// Set this and Kimi K2.6 becomes the default model on first launch.
KIMI_API_KEY =
CURSOR_API_KEY =
+HIGGSFIELD_API_KEY =
OBSIDIAN_API_KEY =
OBSIDIAN_BASE_URL =