@sulala/agent 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/README.md +3 -2
  2. package/context/airtable.md +35 -0
  3. package/context/asana.md +37 -0
  4. package/context/bluesky.md +26 -91
  5. package/context/calendar.md +63 -0
  6. package/context/country-info.md +13 -0
  7. package/context/create-skill.md +128 -0
  8. package/context/discord.md +30 -0
  9. package/context/docs.md +29 -0
  10. package/context/drive.md +49 -0
  11. package/context/dropbox.md +39 -0
  12. package/context/facebook.md +47 -0
  13. package/context/fetch-form-api.md +16 -0
  14. package/context/figma.md +30 -0
  15. package/context/github.md +58 -0
  16. package/context/gmail.md +52 -0
  17. package/context/google.md +28 -0
  18. package/context/hellohub.md +29 -0
  19. package/context/jira.md +46 -0
  20. package/context/linear.md +40 -0
  21. package/context/notion.md +45 -0
  22. package/context/portal-integrations.md +42 -0
  23. package/context/post-to-x.md +50 -0
  24. package/context/sheets.md +47 -0
  25. package/context/slack.md +48 -0
  26. package/context/slides.md +35 -0
  27. package/context/stripe.md +38 -0
  28. package/context/tes.md +7 -0
  29. package/context/test.md +7 -0
  30. package/context/zoom.md +28 -0
  31. package/dist/agent/google/calendar.d.ts +2 -0
  32. package/dist/agent/google/calendar.d.ts.map +1 -0
  33. package/dist/agent/google/calendar.js +119 -0
  34. package/dist/agent/google/calendar.js.map +1 -0
  35. package/dist/agent/google/drive.d.ts +2 -0
  36. package/dist/agent/google/drive.d.ts.map +1 -0
  37. package/dist/agent/google/drive.js +51 -0
  38. package/dist/agent/google/drive.js.map +1 -0
  39. package/dist/agent/google/get-token.d.ts +7 -0
  40. package/dist/agent/google/get-token.d.ts.map +1 -0
  41. package/dist/agent/google/get-token.js +37 -0
  42. package/dist/agent/google/get-token.js.map +1 -0
  43. package/dist/agent/google/gmail.d.ts +2 -0
  44. package/dist/agent/google/gmail.d.ts.map +1 -0
  45. package/dist/agent/google/gmail.js +138 -0
  46. package/dist/agent/google/gmail.js.map +1 -0
  47. package/dist/agent/google/index.d.ts +2 -0
  48. package/dist/agent/google/index.d.ts.map +1 -0
  49. package/dist/agent/google/index.js +13 -0
  50. package/dist/agent/google/index.js.map +1 -0
  51. package/dist/agent/loop.d.ts +8 -0
  52. package/dist/agent/loop.d.ts.map +1 -1
  53. package/dist/agent/loop.js +226 -40
  54. package/dist/agent/loop.js.map +1 -1
  55. package/dist/agent/memory.d.ts +21 -0
  56. package/dist/agent/memory.d.ts.map +1 -0
  57. package/dist/agent/memory.js +33 -0
  58. package/dist/agent/memory.js.map +1 -0
  59. package/dist/agent/pending-actions.d.ts +21 -0
  60. package/dist/agent/pending-actions.d.ts.map +1 -0
  61. package/dist/agent/pending-actions.js +65 -0
  62. package/dist/agent/pending-actions.js.map +1 -0
  63. package/dist/agent/pi-runner.d.ts +27 -0
  64. package/dist/agent/pi-runner.d.ts.map +1 -0
  65. package/dist/agent/pi-runner.js +300 -0
  66. package/dist/agent/pi-runner.js.map +1 -0
  67. package/dist/agent/skill-generate.d.ts +63 -0
  68. package/dist/agent/skill-generate.d.ts.map +1 -0
  69. package/dist/agent/skill-generate.js +128 -0
  70. package/dist/agent/skill-generate.js.map +1 -0
  71. package/dist/agent/skill-install.d.ts.map +1 -1
  72. package/dist/agent/skill-install.js +80 -31
  73. package/dist/agent/skill-install.js.map +1 -1
  74. package/dist/agent/skill-templates.d.ts +17 -0
  75. package/dist/agent/skill-templates.d.ts.map +1 -0
  76. package/dist/agent/skill-templates.js +26 -0
  77. package/dist/agent/skill-templates.js.map +1 -0
  78. package/dist/agent/skills-config.d.ts +24 -2
  79. package/dist/agent/skills-config.d.ts.map +1 -1
  80. package/dist/agent/skills-config.js +107 -8
  81. package/dist/agent/skills-config.js.map +1 -1
  82. package/dist/agent/skills-watcher.js +1 -1
  83. package/dist/agent/skills.d.ts +9 -3
  84. package/dist/agent/skills.d.ts.map +1 -1
  85. package/dist/agent/skills.js +104 -9
  86. package/dist/agent/skills.js.map +1 -1
  87. package/dist/agent/tools.d.ts +25 -3
  88. package/dist/agent/tools.d.ts.map +1 -1
  89. package/dist/agent/tools.integrations.test.d.ts +2 -0
  90. package/dist/agent/tools.integrations.test.d.ts.map +1 -0
  91. package/dist/agent/tools.integrations.test.js +269 -0
  92. package/dist/agent/tools.integrations.test.js.map +1 -0
  93. package/dist/agent/tools.js +692 -39
  94. package/dist/agent/tools.js.map +1 -1
  95. package/dist/ai/orchestrator.d.ts +4 -1
  96. package/dist/ai/orchestrator.d.ts.map +1 -1
  97. package/dist/ai/orchestrator.js +246 -14
  98. package/dist/ai/orchestrator.js.map +1 -1
  99. package/dist/ai/pricing.d.ts +6 -0
  100. package/dist/ai/pricing.d.ts.map +1 -0
  101. package/dist/ai/pricing.js +39 -0
  102. package/dist/ai/pricing.js.map +1 -0
  103. package/dist/channels/discord.d.ts +15 -0
  104. package/dist/channels/discord.d.ts.map +1 -0
  105. package/dist/channels/discord.js +55 -0
  106. package/dist/channels/discord.js.map +1 -0
  107. package/dist/channels/stripe.d.ts +15 -0
  108. package/dist/channels/stripe.d.ts.map +1 -0
  109. package/dist/channels/stripe.js +58 -0
  110. package/dist/channels/stripe.js.map +1 -0
  111. package/dist/channels/telegram.d.ts +60 -0
  112. package/dist/channels/telegram.d.ts.map +1 -0
  113. package/dist/channels/telegram.js +562 -0
  114. package/dist/channels/telegram.js.map +1 -0
  115. package/dist/cli.js +66 -8
  116. package/dist/cli.js.map +1 -1
  117. package/dist/config.d.ts +14 -0
  118. package/dist/config.d.ts.map +1 -1
  119. package/dist/config.js +85 -1
  120. package/dist/config.js.map +1 -1
  121. package/dist/db/index.d.ts +83 -0
  122. package/dist/db/index.d.ts.map +1 -1
  123. package/dist/db/index.js +174 -2
  124. package/dist/db/index.js.map +1 -1
  125. package/dist/db/schema.sql +35 -0
  126. package/dist/gateway/server.d.ts.map +1 -1
  127. package/dist/gateway/server.js +1219 -27
  128. package/dist/gateway/server.js.map +1 -1
  129. package/dist/index.js +149 -6
  130. package/dist/index.js.map +1 -1
  131. package/dist/ollama-setup.d.ts +27 -0
  132. package/dist/ollama-setup.d.ts.map +1 -0
  133. package/dist/ollama-setup.js +191 -0
  134. package/dist/ollama-setup.js.map +1 -0
  135. package/dist/onboard-env.d.ts +1 -1
  136. package/dist/onboard-env.d.ts.map +1 -1
  137. package/dist/onboard-env.js +2 -0
  138. package/dist/onboard-env.js.map +1 -1
  139. package/dist/onboard.d.ts +3 -1
  140. package/dist/onboard.d.ts.map +1 -1
  141. package/dist/onboard.js +7 -2
  142. package/dist/onboard.js.map +1 -1
  143. package/dist/plugins/index.d.ts +10 -0
  144. package/dist/plugins/index.d.ts.map +1 -1
  145. package/dist/plugins/index.js +32 -0
  146. package/dist/plugins/index.js.map +1 -1
  147. package/dist/redact.d.ts +15 -0
  148. package/dist/redact.d.ts.map +1 -0
  149. package/dist/redact.js +56 -0
  150. package/dist/redact.js.map +1 -0
  151. package/dist/scheduler/cron.d.ts +21 -0
  152. package/dist/scheduler/cron.d.ts.map +1 -1
  153. package/dist/scheduler/cron.js +60 -0
  154. package/dist/scheduler/cron.js.map +1 -1
  155. package/dist/system-capabilities.d.ts +11 -0
  156. package/dist/system-capabilities.d.ts.map +1 -0
  157. package/dist/system-capabilities.js +109 -0
  158. package/dist/system-capabilities.js.map +1 -0
  159. package/dist/types.d.ts +62 -3
  160. package/dist/types.d.ts.map +1 -1
  161. package/dist/watcher/index.d.ts +2 -0
  162. package/dist/watcher/index.d.ts.map +1 -1
  163. package/dist/watcher/index.js +31 -1
  164. package/dist/watcher/index.js.map +1 -1
  165. package/dist/workspace-automations.d.ts +16 -0
  166. package/dist/workspace-automations.d.ts.map +1 -0
  167. package/dist/workspace-automations.js +133 -0
  168. package/dist/workspace-automations.js.map +1 -0
  169. package/package.json +19 -3
  170. package/registry/bluesky.md +12 -89
  171. package/registry/skills-registry.json +6 -0
  172. package/src/db/schema.sql +35 -0
  173. package/src/index.ts +159 -6
package/README.md CHANGED
@@ -25,7 +25,7 @@ sulala onboard
25
25
  sulala onboard --install-daemon
26
26
  ```
27
27
 
28
- Then open http://127.0.0.1:3000 (dashboard and API). Add API keys at http://127.0.0.1:3000/onboard if needed.
28
+ Then open http://127.0.0.1:3000 (dashboard and API). **Default LLM is Ollama** (local, no API key). On first run, if Ollama is not installed, the app will start the official installer for your OS (Mac/Linux/Windows). You can optionally add API keys at http://127.0.0.1:3000/onboard to use OpenAI, Claude, Gemini, or OpenRouter instead.
29
29
 
30
30
  **Or run from source (clone this repo):**
31
31
 
@@ -90,7 +90,7 @@ Set `GATEWAY_URL` and optionally `GATEWAY_API_KEY` when using gateway commands.
90
90
 
91
91
  **AI models:** Supported providers: OpenAI (e.g. gpt-5.2, gpt-5-mini, gpt-4o-mini), OpenRouter (one key for many models, e.g. openai/gpt-5.2, anthropic/claude-sonnet-4), Claude (Opus 4.6, Sonnet 4.6, Haiku 4.5), Gemini (2.5 Flash/Pro, 3.1 Pro), Ollama (local). See [docs/MODELS.md](docs/MODELS.md) for model IDs and env vars (`AI_*_DEFAULT_MODEL`, `OPENROUTER_API_KEY`, `GOOGLE_GEMINI_API_KEY`).
92
92
 
93
- **Config:** Watched folders can be set in `.env` (`WATCH_FOLDERS`) or in `config/watched.json` (array `folders`); both are merged. Optional gateway auth via `GATEWAY_API_KEY`; webhooks via `WEBHOOK_URL` or `WEBHOOK_URLS` and optional `WEBHOOK_SECRET`. AI: set `OPENAI_API_KEY`, `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, and/or use Ollama (`OLLAMA_BASE_URL`, default `http://localhost:11434`). Optional rate limit: `RATE_LIMIT_MAX`, `RATE_LIMIT_WINDOW_MS`. See `.env.example`.
93
+ **Config:** Watched folders can be set in `.env` (`WATCH_FOLDERS`) or in `config/watched.json` (array `folders`); both are merged. Optional gateway auth via `GATEWAY_API_KEY`; webhooks via `WEBHOOK_URL` or `WEBHOOK_URLS` and optional `WEBHOOK_SECRET`. AI: default is **Ollama** (no key; installs automatically if missing). Override with `AI_DEFAULT_PROVIDER=openai` (or `claude`, `gemini`, `openrouter`) and set the corresponding API keys. Optional: `OLLAMA_BASE_URL` (default `http://localhost:11434`). Optional rate limit: `RATE_LIMIT_MAX`, `RATE_LIMIT_WINDOW_MS`. See `.env.example`.
94
94
 
95
95
  **Docker:** Build and run with dashboard served from the gateway:
96
96
  ```bash
@@ -133,3 +133,4 @@ sulala_agent/
133
133
  ## License
134
134
 
135
135
  MIT
136
+ # agent
@@ -0,0 +1,35 @@
1
+ ---
2
+ name: airtable
3
+ description: Use Airtable (bases, records) via the Portal. When the user asks about Airtable bases or records, list connections with list_integrations_connections (provider airtable) and use run_command with curl to the Airtable API or gateway.
4
+ metadata:
5
+ {
6
+ "sulala": {
7
+ "emoji": "📊",
8
+ "requires": { "bins": ["curl"] }
9
+ }
10
+ }
11
+ ---
12
+
13
+ # Airtable
14
+
15
+ 1. **list_integrations_connections** with `provider: "airtable"` → get `connection_id`.
16
+ 2. **get_connection_token** with that `connection_id` → returns `accessToken` (do not curl the portal).
17
+ 3. **run_command (curl)** with `Authorization: Bearer <accessToken>` and `Content-Type: application/json`.
18
+
19
+ Airtable API uses **base ID** and **table name or ID**. Add `api.airtable.com` to **ALLOWED_CURL_HOSTS**. Official docs: https://airtable.com/developers/web/api/introduction
20
+
21
+ ---
22
+
23
+ ## Bases and tables
24
+
25
+ - **List bases**: `GET https://api.airtable.com/v0/meta/bases`. Returns `bases[].id`, `bases[].name`. Use base `id` in table URLs.
26
+ - **List tables** (schema) in a base: `GET https://api.airtable.com/v0/meta/bases/<baseId>/tables`. Returns table names and field definitions.
27
+
28
+ ---
29
+
30
+ ## Records
31
+
32
+ - **List records** (one table): `GET https://api.airtable.com/v0/<baseId>/<tableNameOrId>?maxRecords=20`. Optional: `?filterByFormula=<formula>`, `?sort[0][field]=Name`. Returns `records[].id`, `records[].fields`.
33
+ - **Create record**: `POST https://api.airtable.com/v0/<baseId>/<tableNameOrId>` with body `{"fields": {"Name": "Value", "OtherField": "Value"}}`. Field names must match the table schema.
34
+
35
+ Requirements: **PORTAL_GATEWAY_URL**, **PORTAL_API_KEY**; user must connect Airtable in the Portal. Base must be shared with the connected account.
@@ -0,0 +1,37 @@
1
+ ---
2
+ name: asana
3
+ description: Use Asana (workspaces, projects, tasks) via the Portal. When the user asks about Asana tasks or projects, list connections with list_integrations_connections (provider asana) and use run_command with curl to the Asana API or gateway.
4
+ metadata:
5
+ {
6
+ "sulala": {
7
+ "emoji": "✅",
8
+ "requires": { "bins": ["curl"] }
9
+ }
10
+ }
11
+ ---
12
+
13
+ # Asana
14
+
15
+ 1. **list_integrations_connections** with `provider: "asana"` → get `connection_id`.
16
+ 2. **get_connection_token** with that `connection_id` → returns `accessToken` (do not curl the portal).
17
+ 3. **run_command (curl)** with `Authorization: Bearer <accessToken>` and `Content-Type: application/json`.
18
+
19
+ Base URL: `https://app.asana.com/api/1.0`. Add `app.asana.com` to **ALLOWED_CURL_HOSTS**. Official docs: https://developers.asana.com/reference/rest-api-reference
20
+
21
+ ---
22
+
23
+ ## Workspaces and projects
24
+
25
+ - **List workspaces**: `GET https://app.asana.com/api/1.0/workspaces`. Returns `data[].gid`, `data[].name`.
26
+ - **List projects** (in workspace): `GET https://app.asana.com/api/1.0/workspaces/<workspace_gid>/projects`. Returns `data[].gid`, `data[].name`.
27
+
28
+ ---
29
+
30
+ ## Tasks
31
+
32
+ - **List tasks** (in project): `GET https://app.asana.com/api/1.0/projects/<project_gid>/tasks?opt_fields=name,completed,due_on,notes`. Returns `data[].gid`, `data[].name`, etc. Pagination: `offset`, `limit`.
33
+ - **Create task**: `POST https://app.asana.com/api/1.0/tasks` with header `Content-Type: application/json` and body `{"data": {"name": "Task title", "projects": ["<project_gid>"]}}`. The `projects` value must be an array of one or more project GID **strings** from `GET /workspaces/<workspace_gid>/projects` (e.g. `["1213334948480995"]`). Do **not** send `workspace` when sending `projects`—the API will error. Optional in `data`: `"notes": "Description"`, `"due_on": "YYYY-MM-DD"`. Flow: (1) GET `/workspaces` → pick a workspace `gid`, (2) GET `/workspaces/<workspace_gid>/projects` → pick a project `gid`, (3) POST `/tasks` with `{"data": {"name": "fix bug today", "projects": ["<project_gid>"]}}`.
34
+ - **Get task**: `GET https://app.asana.com/api/1.0/tasks/<task_gid>?opt_fields=name,completed,notes,due_on,projects`.
35
+ - **Update task** (mark complete, etc.): `PUT https://app.asana.com/api/1.0/tasks/<task_gid>` with body `{"data": {"completed": true}}` or `{"data": {"name": "New name"}}`.
36
+
37
+ Requirements: **PORTAL_GATEWAY_URL**, **PORTAL_API_KEY**; user must connect Asana in the Portal.
@@ -1,111 +1,46 @@
1
1
  ---
2
2
  name: bluesky
3
- description: Post to Bluesky (AT Protocol). Use when the user asks to post a tweet/thread to Bluesky, share content on Bluesky, or post news/headlines from a source (URL or text) to Bluesky.
4
- homepage: https://bsky.app
3
+ description: Post to Bluesky (AT Protocol). Use when the user asks to post to Bluesky or share content on Bluesky. Use either (A) Portal OAuth connection and the Bluesky proxy, or (B) app password from skill config (BSKY_HANDLE, BSKY_APP_PASSWORD).
5
4
  metadata:
6
5
  {
7
6
  "sulala": {
8
7
  "emoji": "🦋",
9
- "requires": { "bins": ["curl", "python3", "sh"], "env": ["BSKY_HANDLE", "BSKY_APP_PASSWORD"] },
10
- "primaryEnv": "BSKY_APP_PASSWORD"
8
+ "requires": { "bins": ["curl"] }
11
9
  }
12
10
  }
13
11
  ---
14
12
 
15
- # Bluesky Posting
13
+ # Bluesky
16
14
 
17
- Post to Bluesky via the AT Protocol. Use **run_command** with `curl`, `python3`, and `sh`. Add `curl`, `python3`, and `sh` to ALLOWED_BINARIES.
15
+ Two ways to post:
18
16
 
19
- Requires `BSKY_HANDLE` (e.g. `your.bsky.social`) and `BSKY_APP_PASSWORD` (app password from bsky.app → Settings → App Passwords). Set them in `.env` or in the skill config (`~/.sulala/config.json` or `.sulala/config.json` in the project; see Skill config in the dashboard). Config is re-read when the file changes; enable/disable takes effect without restart.
17
+ ## (A) Portal OAuth (recommended)
20
18
 
21
- **IMPORTANT:** `run_command` does not run a shell — `$BSKY_HANDLE` and `$BSKY_APP_PASSWORD` will not expand if you call curl directly. Always use `binary: "sh"` and `args: ["-c", "curl ... $BSKY_HANDLE ... $BSKY_APP_PASSWORD ..."]` so the env vars expand.
19
+ 1. **list_integrations_connections** with `provider: "bluesky"` get `connection_id`.
20
+ 2. **bluesky_post** with that `connection_id` and the post text (max 300 characters).
22
21
 
23
- ## When to Use
22
+ Use the **bluesky_post** tool for posting. Do not use run_command (curl) for Bluesky—the correct endpoint is the portal gateway bsky-request, and the tool calls it for you.
24
23
 
25
- - "Post this to Bluesky"
26
- - "Share [content] on Bluesky"
27
- - "Post news from [URL] to Bluesky"
28
- - "Post a headline about [topic]"
24
+ ## (B) App password (skill config)
29
25
 
30
- ## Post Flow (non-interactive)
26
+ Set **BSKY_HANDLE** and **BSKY_APP_PASSWORD** (from bsky.app → Settings → App Passwords) in Skills → Bluesky config.
31
27
 
32
- ### 1. Ensure credentials
28
+ 1. **Create session** to get access token:
29
+ ```bash
30
+ curl -s -X POST "https://bsky.social/xrpc/com.atproto.server.createSession" \
31
+ -H "Content-Type: application/json" \
32
+ -d '{"identifier":"$BSKY_HANDLE","password":"$BSKY_APP_PASSWORD"}'
33
+ ```
34
+ Response has `accessJwt` and `did`.
33
35
 
34
- ```
35
- BSKY_HANDLE="${BSKY_HANDLE:?Set BSKY_HANDLE}"
36
- BSKY_APP_PASSWORD="${BSKY_APP_PASSWORD:?Set BSKY_APP_PASSWORD}"
37
- ```
36
+ 2. **Create post** (use the JWT and did from step 1):
37
+ ```bash
38
+ curl -s -X POST "https://bsky.social/xrpc/com.atproto.repo.createRecord" \
39
+ -H "Content-Type: application/json" \
40
+ -H "Authorization: Bearer <accessJwt>" \
41
+ -d '{"repo":"<did>","collection":"app.bsky.feed.post","record":{"$type":"app.bsky.feed.post","text":"<post text>","createdAt":"<ISO8601>"}}'
42
+ ```
38
43
 
39
- If empty, read from Sulala config:
44
+ Add `bsky.social` to **ALLOWED_CURL_HOSTS**. Optional: **BSKY_PDS** (default https://bsky.social) for a custom PDS.
40
45
 
41
- ```
42
- CONFIG_PATH="${SULALA_CONFIG_PATH:-$HOME/.sulala/config.json}"
43
- # If using workspace config, use: CONFIG_PATH=".sulala/config.json" (when run from project root)
44
- BSKY_HANDLE=$(cat "$CONFIG_PATH" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); e=d.get('skills',{}).get('entries',{}).get('bluesky',{}); print(e.get('handle','') or e.get('apiKey',''))" 2>/dev/null)
45
- BSKY_APP_PASSWORD=$(cat "$CONFIG_PATH" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); e=d.get('skills',{}).get('entries',{}).get('bluesky',{}); print(e.get('apiKey','') or e.get('password',''))" 2>/dev/null)
46
- ```
47
-
48
- (Config may use `handle` + `apiKey` for Bluesky. Adjust field names if your config differs.)
49
-
50
- ### 2. Create session (login)
51
-
52
- **Use `run_command` with `binary: "sh"` and `args: ["-c", "..."]`** so `$BSKY_HANDLE` and `$BSKY_APP_PASSWORD` expand from the environment. Do NOT call curl directly.
53
-
54
- ```bash
55
- sh -c 'SESSION=$(curl -s -X POST "https://bsky.social/xrpc/com.atproto.server.createSession" -H "Content-Type: application/json" -d "{\"identifier\": \"$BSKY_HANDLE\", \"password\": \"$BSKY_APP_PASSWORD\"}"); echo "$SESSION"'
56
- ```
57
-
58
- Then parse the JSON output with `python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('accessJwt',''), d.get('did',''))"` to get `ACCESS_TOKEN` and `DID`.
59
-
60
- If the session response contains `"error"` or `"AuthenticationRequired"`, report: "Bluesky login failed. Check BSKY_HANDLE and BSKY_APP_PASSWORD in .env."
61
-
62
- ### 3. Create post
63
-
64
- Text must be JSON-escaped. Use python to build the payload. **Use `run_command` with `binary: "sh"`** so `$ACCESS_TOKEN`, `$DID`, and other vars expand.
65
-
66
- Run a single `sh -c` that chains steps 2 and 3 so SESSION, ACCESS_TOKEN, DID persist in the same shell:
67
-
68
- ```bash
69
- sh -c '
70
- SESSION=$(curl -s -X POST "https://bsky.social/xrpc/com.atproto.server.createSession" \
71
- -H "Content-Type: application/json" \
72
- -d "{\"identifier\": \"$BSKY_HANDLE\", \"password\": \"$BSKY_APP_PASSWORD\"}");
73
- ACCESS_TOKEN=$(echo "$SESSION" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get(\"accessJwt\",\"\"))");
74
- DID=$(echo "$SESSION" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get(\"did\",\"\"))");
75
- if [ -z "$ACCESS_TOKEN" ]; then echo "Login failed"; exit 1; fi;
76
- POST_TEXT="Your post content here";
77
- POST_JSON=$(python3 -c "import sys,json; print(json.dumps(sys.argv[1]))" "$POST_TEXT");
78
- NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z");
79
- curl -s -X POST "https://bsky.social/xrpc/com.atproto.repo.createRecord" \
80
- -H "Authorization: Bearer $ACCESS_TOKEN" \
81
- -H "Content-Type: application/json" \
82
- -d "{\"repo\": \"$DID\", \"collection\": \"app.bsky.feed.post\", \"record\": {\"\\$type\": \"app.bsky.feed.post\", \"text\": $POST_JSON, \"createdAt\": \"$NOW\"}}"
83
- '
84
- ```
85
-
86
- Replace `Your post content here` with the user's post text.
87
-
88
- If the response contains `"uri"`, the post succeeded. Otherwise report the error.
89
-
90
- ## Posting from a news source
91
-
92
- When the user wants to post **news** or **content from a URL**:
93
-
94
- 1. **Fetch the content** (e.g. with `curl -s <URL>` or read a file).
95
- 2. **Summarize** if the content is long — Bluesky posts are max 300 characters. Write a short headline or summary.
96
- 3. **Post** using the flow above. Optionally append the source URL if it fits within 300 chars.
97
-
98
- Example for "post news from https://example.com/article":
99
- - Fetch the page, extract title/lead.
100
- - Build post: "Headline here — https://example.com/article"
101
- - Post via the createRecord flow above.
102
-
103
- ## Character limit
104
-
105
- Bluesky posts are limited to 300 characters. If the user's text or your summary exceeds that, truncate or split into multiple posts (thread). For a thread, create each post separately; the API does not natively support threads — you would need to reference the previous post's URI if linking them (advanced).
106
-
107
- ## Notes
108
-
109
- - Use an **app password**, not the main account password. Create at bsky.app → Settings → App Passwords.
110
- - `bsky.social` is the default PDS; custom PDS users would need a different host.
111
- - Do not embed real credentials in the skill or in replies — use env or config.
46
+ Official docs: https://docs.bsky.app/docs/api/atproto/
@@ -0,0 +1,63 @@
1
+ ---
2
+ name: calendar
3
+ description: Use Google Calendar via the Portal. When the user asks to create a calendar event, add to calendar, list events, or check calendar, use this skill with list_integrations_connections (provider calendar) and run_command + curl. Do not use Apple Calendar, osascript, or local calendar apps—use Google Calendar via the Portal.
4
+ metadata:
5
+ {
6
+ "sulala": {
7
+ "emoji": "📅",
8
+ "requires": { "bins": ["curl"] }
9
+ }
10
+ }
11
+ ---
12
+
13
+ # Google Calendar
14
+
15
+ Use **list_integrations_connections** with `provider: "calendar"`, then **get_connection_token** to get an OAuth token (do not curl the portal from run_command—use the tool). Then call Calendar with that token.
16
+
17
+ 1. **list_integrations_connections** with `provider: "calendar"` → get `connection_id`.
18
+ 2. **get_connection_token** with that `connection_id` → returns `accessToken` (runs server-side).
19
+ 3. **run_command (curl)** — call Calendar APIs with `Authorization: Bearer <accessToken>` for all requests below.
20
+
21
+ Add `www.googleapis.com` to **ALLOWED_CURL_HOSTS**.
22
+
23
+ Base URL: `https://www.googleapis.com/calendar/v3`
24
+
25
+ ---
26
+
27
+ ## List calendars
28
+
29
+ `GET https://www.googleapis.com/calendar/v3/users/me/calendarList`. Use `items[].id` (e.g. `primary`) for listing events.
30
+
31
+ ---
32
+
33
+ ## List events
34
+
35
+ `GET https://www.googleapis.com/calendar/v3/calendars/<calendarId>/events?timeMin=<ISO8601>&timeMax=<ISO8601>&maxResults=20&singleEvents=true`. `calendarId` is often `primary`.
36
+
37
+ ---
38
+
39
+ ## Create event
40
+
41
+ **Use this for all "create a calendar event" or "add to calendar" requests.**
42
+
43
+ `POST https://www.googleapis.com/calendar/v3/calendars/primary/events` with `Content-Type: application/json`, body:
44
+
45
+ `{"summary": "Event title", "description": "Optional description", "start": {"dateTime": "2025-03-04T21:00:00", "timeZone": "America/New_York"}, "end": {"dateTime": "2025-03-04T21:30:00", "timeZone": "America/New_York"}}`
46
+
47
+ - Example for "gym at 9 PM" today: use today's date, start 21:00, end 21:30 (or 22:00) in the user's timezone.
48
+ - All-day: use `"start": {"date": "2025-03-15"}`, `"end": {"date": "2025-03-16"}`.
49
+ - Times in ISO8601 with timezone (e.g. `America/New_York` or `UTC`).
50
+
51
+ ---
52
+
53
+ ## Update event
54
+
55
+ `PUT https://www.googleapis.com/calendar/v3/calendars/<calendarId>/events/<eventId>` with same body shape as create.
56
+
57
+ ---
58
+
59
+ ## Delete event
60
+
61
+ `DELETE https://www.googleapis.com/calendar/v3/calendars/<calendarId>/events/<eventId>`.
62
+
63
+ Requirements: **PORTAL_GATEWAY_URL**, **PORTAL_API_KEY**; user must connect Google Calendar in the Portal or dashboard Integrations.
@@ -0,0 +1,13 @@
1
+ ---
2
+ name: country-info
3
+ description: Provides small info about various countries. Use when the user asks for information about a specific country.
4
+ ---
5
+ # Country Info
6
+
7
+ Use when the user asks for small information about a country, such as facts, culture, and geography.
8
+
9
+ ## How to use
10
+ - Ask for specific details about a country (e.g., "Tell me about Canada").
11
+
12
+ ## Limits
13
+ - The information provided is brief and may not cover all aspects of the country.
@@ -0,0 +1,128 @@
1
+ ---
2
+ name: create-skill
3
+ description: Create a new Sulala skill when the user asks for one. Use write_file to create the .md file. Use when the user asks to create, add, or write a skill.
4
+ ---
5
+
6
+ # Create Sulala Skill
7
+
8
+ When the user asks to create a skill (e.g. "create a skill for X", "add a skill that does Y"), create it using **write_file**. Infer the skill from the request; ask only if critical details are missing.
9
+
10
+ ## Skill format
11
+
12
+ Every Sulala skill is a `.md` file with YAML frontmatter and a markdown body:
13
+
14
+ ```markdown
15
+ ---
16
+ name: slug-or-name
17
+ description: One-line summary of when to use this skill. Include trigger terms.
18
+ ---
19
+ # Skill Title
20
+
21
+ Use when the user asks for X, Y, or Z.
22
+
23
+ ## How to use
24
+ - Commands, steps, or run_command examples
25
+ ## Limits
26
+ - What not to do
27
+ ```
28
+
29
+ **Required frontmatter:** `name` (slug, lowercase-hyphens), `description` (third person, includes WHAT and WHEN).
30
+
31
+ **Required when the skill uses run_command or external APIs:** `metadata` with `sulala.requires`:
32
+ - **bins** — list of CLI tools (e.g. `["curl", "jq"]`). Add these to ALLOWED_BINARIES. Always include if the skill uses run_command.
33
+ - **env** — list of required env var names for API keys (e.g. `["TMDB_ACCESS_TOKEN"]`). Users configure these in Skills config.
34
+
35
+ ```yaml
36
+ metadata:
37
+ {
38
+ "sulala": {
39
+ "requires": {
40
+ "bins": ["curl"],
41
+ "env": ["TMDB_ACCESS_TOKEN"]
42
+ }
43
+ }
44
+ }
45
+ ```
46
+
47
+ ## Where to write
48
+
49
+ Use the **SKILL.md flow** (direct skill directory, not packaged .skill):
50
+
51
+ 1. **Use the path from the Workspace section:** In "## Context" → **## Workspace**, the prompt gives **Your skill output directory**. Use that path with write_file: `<that-directory>/<slug>/SKILL.md`. It is already resolved for the current OS. Do **not** use `~` or `$HOME`; the tool does not expand them and would create a literal folder under the project.
52
+ 2. Add `SKILL.md` with YAML frontmatter (`name`, `description`) and instructions.
53
+ 3. Add any `scripts/`, `references/`, or `assets/` under that folder if needed.
54
+ 4. Do **not** create skills under the project folder (e.g. `context/`) — they will be overwritten on project updates.
55
+
56
+ If write_file cannot write to the path from the Workspace section (e.g. permission or workspace root restriction), tell the user: "Create the skill at [that path], then refresh skills or restart the gateway."
57
+
58
+ ## Steps
59
+
60
+ 1. Infer purpose and name from the user's request.
61
+ 2. Draft frontmatter (name, description) and body (when to use, how to use, limits).
62
+ 3. Call **write_file** with `path` = the skill output directory from **## Workspace** + `/<slug>/SKILL.md` (e.g. `/Users/you/.sulala/workspace/skills/<slug>/SKILL.md`). Create the directory if needed.
63
+ 4. Confirm: "Created skill at [path]. Refresh skills or restart the gateway to load it."
64
+
65
+ ## Example (API skill with metadata)
66
+
67
+ User: "Create a skill that fetches movie data from TMDB"
68
+
69
+ Include **metadata** with `bins` (curl) and `env` (API key). Use write_file with the path from **## Workspace** + `/fetch-movie/SKILL.md`:
70
+
71
+ ```markdown
72
+ ---
73
+ name: fetch-movie
74
+ description: Fetch movies using TMDB API via curl. Use when the user asks for movie details or to search movies by title.
75
+ metadata:
76
+ {
77
+ "sulala": {
78
+ "requires": {
79
+ "bins": ["curl"],
80
+ "env": ["TMDB_ACCESS_TOKEN"]
81
+ }
82
+ }
83
+ }
84
+ ---
85
+
86
+ # Fetch Movie
87
+
88
+ Use **run_command** with `curl` to get movie data from The Movie Database (TMDB) API. Add `curl` to ALLOWED_BINARIES. Store your TMDB API token in Skills config as `TMDB_ACCESS_TOKEN`.
89
+
90
+ ## TMDB Search API
91
+
92
+ - **Search:** `curl -s -H "Authorization: Bearer $TMDB_ACCESS_TOKEN" "https://api.themoviedb.org/3/search/movie?query=QUERY"`
93
+
94
+ ## Limits
95
+
96
+ - Do not expose the token in responses. Use the env var in run_command.
97
+ ```
98
+
99
+ ## Example (stock price)
100
+
101
+ User: "Create a skill that fetches stock prices"
102
+
103
+ Use write_file with the path from **## Workspace** + `/stock-price/SKILL.md`:
104
+
105
+ ```markdown
106
+ ---
107
+ name: stock-price
108
+ description: Fetches stock prices via API. Use when the user asks for stock quotes, share price, or market data.
109
+ ---
110
+
111
+ # Stock Price
112
+
113
+ Use **run_command** with `curl` to call a stock API. Add `curl` to ALLOWED_BINARIES.
114
+
115
+ ## Alpha Vantage (requires API key)
116
+
117
+ curl -s "https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol=AAPL&apikey=$ALPHA_VANTAGE_API_KEY"
118
+
119
+ ## Limits
120
+
121
+ - Do not share API keys in responses.
122
+ ```
123
+
124
+ ## Tips
125
+
126
+ - Description: third person, specific, include trigger terms. Example: "Fetches weather for a city. Use when the user asks for weather, temperature, or forecast."
127
+ - Body: concise; if the skill uses run_command, say which binaries and add to ALLOWED_BINARIES.
128
+ - Slug: lowercase, hyphens only (e.g. `my-new-skill`).
@@ -0,0 +1,30 @@
1
+ ---
2
+ name: discord
3
+ description: Use Discord (servers, channels, send messages) via Settings → Channels. Do not use list_integrations_connections for Discord—it only lists OAuth apps. Use discord_list_guilds, discord_list_channels, and discord_send_message (token from Settings → Channels).
4
+ metadata:
5
+ {
6
+ "sulala": {
7
+ "emoji": "🎮",
8
+ "requires": { "env": ["DISCORD_BOT_TOKEN"] }
9
+ }
10
+ }
11
+ ---
12
+
13
+ # Discord
14
+
15
+ Discord is configured in **Settings → Channels (Discord)** or via **DISCORD_BOT_TOKEN** in the agent env. **Do not call list_integrations_connections for Discord**—that tool only returns OAuth connections (Gmail, Slack, etc.). Discord uses a bot token, not OAuth.
16
+
17
+ Use the dedicated tools (they use the token from Settings):
18
+ - **discord_list_guilds** — list servers (guilds) the bot is in. Returns guild id and name.
19
+ - **discord_list_channels** — list channels in a guild. Requires guild_id from discord_list_guilds. Returns channel id, name, type (0=text, 2=voice, 4=category).
20
+ - **discord_send_message** — send a message to a channel. Requires channel_id and content (max 2000 chars).
21
+
22
+ ---
23
+
24
+ ## Flow
25
+
26
+ 1. **discord_list_guilds** — get server (guild) ids and names.
27
+ 2. **discord_list_channels** with `guild_id` — get channel ids (type 0 = text channel).
28
+ 3. **discord_send_message** with `channel_id` and `content` — send the message.
29
+
30
+ Requirements: Bot must be added to the server and have permissions to read channels and send messages.
@@ -0,0 +1,29 @@
1
+ ---
2
+ name: docs
3
+ description: Use Google Docs via the Portal. When the user asks to list, create, or read Google Docs, use this skill with list_integrations_connections (provider docs) and run_command + curl.
4
+ metadata:
5
+ {
6
+ "sulala": {
7
+ "emoji": "📄",
8
+ "requires": { "bins": ["curl"] }
9
+ }
10
+ }
11
+ ---
12
+
13
+ # Google Docs
14
+
15
+ Use **list_integrations_connections** with `provider: "docs"`, then **get_connection_token** (do not curl the portal). Then call the API with that token.
16
+
17
+ 1. **list_integrations_connections** with `provider: "docs"` → get `connection_id`.
18
+ 2. **get_connection_token** with that `connection_id` → returns `accessToken`.
19
+ 3. **run_command (curl)** with `Authorization: Bearer <accessToken>` for all requests.
20
+
21
+ Add `www.googleapis.com` to **ALLOWED_CURL_HOSTS**.
22
+
23
+ ---
24
+
25
+ ## List / create / read
26
+
27
+ List and create Docs via the Drive API (mimeType `application/vnd.google-apps.document`). Export to read: `GET https://www.googleapis.com/drive/v3/files/<id>/export?mimeType=text/plain` with `Authorization: Bearer <accessToken>`.
28
+
29
+ Requirements: **PORTAL_GATEWAY_URL**, **PORTAL_API_KEY**; user must connect Google Docs in the Portal or dashboard Integrations.
@@ -0,0 +1,49 @@
1
+ ---
2
+ name: drive
3
+ description: Use Google Drive via the Portal. When the user asks to list files, create a folder, upload/download files, or manage Drive content, use this skill with list_integrations_connections (provider drive) and run_command + curl.
4
+ metadata:
5
+ {
6
+ "sulala": {
7
+ "emoji": "📁",
8
+ "requires": { "bins": ["curl"] }
9
+ }
10
+ }
11
+ ---
12
+
13
+ # Google Drive
14
+
15
+ Use **list_integrations_connections** with `provider: "drive"`, then **get_connection_token** (do not curl the portal). Then call Drive with that token.
16
+
17
+ 1. **list_integrations_connections** with `provider: "drive"` → get `connection_id`.
18
+ 2. **get_connection_token** with that `connection_id` → returns `accessToken`.
19
+ 3. **run_command (curl)** with `Authorization: Bearer <accessToken>` for all requests below.
20
+
21
+ Add `www.googleapis.com` to **ALLOWED_CURL_HOSTS**.
22
+
23
+ Base URL: `https://www.googleapis.com/drive/v3`
24
+
25
+ ---
26
+
27
+ ## List files
28
+
29
+ `GET https://www.googleapis.com/drive/v3/files?pageSize=20&q=<query>` (e.g. `q='root' in parents` for root; `q=trashed=false`). Returns `files[].id`, `name`, `mimeType`.
30
+
31
+ ---
32
+
33
+ ## Create folder
34
+
35
+ `POST https://www.googleapis.com/drive/v3/files` with body `{"name": "FolderName", "mimeType": "application/vnd.google-apps.folder"}`. Optional: `"parents": ["<folderId>"]`.
36
+
37
+ ---
38
+
39
+ ## Upload file (small)
40
+
41
+ Use multipart: one part JSON `{"name": "filename.txt", "parents": ["<folderId>"]}`, second part file content. Or use resumable upload (see Drive API docs).
42
+
43
+ ---
44
+
45
+ ## Download file
46
+
47
+ `GET https://www.googleapis.com/drive/v3/files/<fileId>?alt=media` with `Authorization: Bearer <token>` (binary response). For export of Google Docs/Sheets use `GET https://www.googleapis.com/drive/v3/files/<fileId>/export?mimeType=text/plain` (or other export type).
48
+
49
+ Requirements: **PORTAL_GATEWAY_URL**, **PORTAL_API_KEY**; user must connect Google Drive in the Portal or dashboard Integrations.
@@ -0,0 +1,39 @@
1
+ ---
2
+ name: dropbox
3
+ description: Use Dropbox (files) via the Portal. When the user asks to list, upload, or download Dropbox files, list connections with list_integrations_connections (provider dropbox) and use run_command with curl to the Dropbox API or gateway.
4
+ metadata:
5
+ {
6
+ "sulala": {
7
+ "emoji": "📁",
8
+ "requires": { "bins": ["curl"] }
9
+ }
10
+ }
11
+ ---
12
+
13
+ # Dropbox
14
+
15
+ 1. **list_integrations_connections** with `provider: "dropbox"` → get `connection_id`.
16
+ 2. **get_connection_token** with that `connection_id` → returns `accessToken` (do not curl the portal).
17
+ 3. **run_command (curl)** with `Authorization: Bearer <accessToken>`. RPC-style endpoints use `Content-Type: application/json` and body; content endpoints use `Dropbox-API-Arg` header.
18
+
19
+ Add `api.dropboxapi.com` and `content.dropboxapi.com` to **ALLOWED_CURL_HOSTS**. Official docs: https://www.dropbox.com/developers/documentation/http/documentation
20
+
21
+ ---
22
+
23
+ ## List folder
24
+
25
+ - **List folder**: `POST https://api.dropboxapi.com/2/files/list_folder` with body `{"path": ""}` for root or `{"path": "/FolderName"}`. Returns `entries[].name`, `entries[].id`, `entries[].tag` (file/folder). Pagination: `cursor` in response, then `POST https://api.dropboxapi.com/2/files/list_folder/continue` with `{"cursor": "..."}`.
26
+
27
+ ---
28
+
29
+ ## Download file
30
+
31
+ - **Download**: `POST https://content.dropboxapi.com/2/files/download` with header `Dropbox-API-Arg: {"path": "/path/to/file.txt"}`. No body. Response body is file content (binary). Use same `Authorization: Bearer <token>`.
32
+
33
+ ---
34
+
35
+ ## Upload file
36
+
37
+ - **Upload** (small file): `POST https://content.dropboxapi.com/2/files/upload` with header `Dropbox-API-Arg: {"path": "/path/to/destination.txt", "mode": "add"}` and `Content-Type: application/octet-stream`, body = raw file bytes. `mode`: "add" (always add), "overwrite", "add" with autorename.
38
+
39
+ Requirements: **PORTAL_GATEWAY_URL**, **PORTAL_API_KEY**; user must connect Dropbox in the Portal.
@@ -0,0 +1,47 @@
1
+ ---
2
+ name: facebook
3
+ description: Use Facebook (Graph API, pages) via the Portal. When the user asks to post or manage Facebook content, list connections with list_integrations_connections (provider facebook) and use run_command with curl to the Graph API or gateway.
4
+ metadata:
5
+ {
6
+ "sulala": {
7
+ "emoji": "📘",
8
+ "requires": { "bins": ["curl"] }
9
+ }
10
+ }
11
+ ---
12
+
13
+ # Facebook
14
+
15
+ 1. **list_integrations_connections** with `provider: "facebook"` → get `connection_id`.
16
+ 2. **get_connection_token** with that `connection_id` → returns `accessToken` (do not curl the portal).
17
+ 3. **run_command (curl)** to `https://graph.facebook.com`. Use `access_token=<accessToken>` in query or `Authorization: Bearer <accessToken>` in header per endpoint.
18
+
19
+ Add `graph.facebook.com` to **ALLOWED_CURL_HOSTS**. Official docs: https://developers.facebook.com/docs/graph-api
20
+
21
+ ---
22
+
23
+ ## User and pages
24
+
25
+ - **Get user info**: `GET https://graph.facebook.com/me?fields=id,name,email&access_token=<token>`. Do **not** use this id as the target for posting (it is a person, not a page).
26
+ - **Get user's pages** (required before any post): `GET https://graph.facebook.com/me/accounts?access_token=<token>`. Returns `data[].id` (page id), `data[].name`, `data[].access_token` (page token). Use these for posting.
27
+
28
+ ---
29
+
30
+ ## Post to page (required flow)
31
+
32
+ **Never post to the user's ID or /me.** The user ID from `GET /me` is a person, not a page. You must use a **page** id and **page** access token from `me/accounts`. **Never use placeholders** like `your_page_id` or `<page_id>` in real API calls—Facebook will reject them. Always get real values from step 2.
33
+
34
+ 1. Get **user** token: `list_integrations_connections` → `get_connection_token` → `accessToken`.
35
+ 2. **Call** `GET https://graph.facebook.com/v25.0/me/accounts?access_token=<user_token>`. Response: `data[]` with `id`, `name`, `access_token` (page token). You must do this before posting; the POST needs real `id` and `access_token` from this response.
36
+ 3. Pick a page: if the user said a name (e.g. "playoutdoor"), find the item in `data` whose `name` matches (e.g. "PlayOutdoor : Tennis, Bike & Run"); otherwise use `data[0]`. Let `PAGE_ID` = that item’s `id`, `PAGE_TOKEN` = that item’s `access_token`.
37
+ 4. Post: `POST https://graph.facebook.com/v25.0/PAGE_ID/feed` with body `{"message": "Your text here", "access_token": "PAGE_TOKEN"}`. Use the actual string values for PAGE_ID and PAGE_TOKEN from step 3 (e.g. if id is `677659355438097`, the URL is `.../v25.0/677659355438097/feed`).
38
+
39
+ - **Photo**: `POST https://graph.facebook.com/v25.0/PAGE_ID/photos` with the **page** token. The image must be at a **publicly reachable URL** — Facebook's servers fetch it. **URLs like http://127.0.0.1 or http://localhost do not work** (Facebook cannot reach your machine). Use a public URL (e.g. deploy the gateway or expose it via ngrok and use that base URL for uploads). When the user attaches media in chat, the message includes "[Attached media ... URLs]"; use one of those URLs only if it is public. Use **form data** (not JSON) for the photos endpoint. Example with curl: `curl -X POST "https://graph.facebook.com/v25.0/PAGE_ID/photos" -F "url=IMAGE_PUBLIC_URL" -F "caption=Optional caption" -F "access_token=PAGE_TOKEN"`. Replace PAGE_ID, IMAGE_PUBLIC_URL, and PAGE_TOKEN with real values from step 3. For multiple images, post one at a time.
40
+
41
+ ---
42
+
43
+ ## Page insights (optional)
44
+
45
+ - **Page insights**: `GET https://graph.facebook.com/<page_id>/insights?metric=page_impressions&access_token=<page_token>`.
46
+
47
+ Requirements: **PORTAL_GATEWAY_URL**, **PORTAL_API_KEY**; user must connect Facebook in the Portal. For posting to a Page, the Facebook app must request (and the user grant) **pages_manage_posts** and **pages_read_engagement**; the token used for the POST must be the **page** access token from `me/accounts`, not the user token. If photo post fails: ensure the image URL is **public** (not 127.0.0.1/localhost), use form params (`-F`) for `/photos`, and use the page token from `me/accounts`.