socialsforai 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,13 @@
1
+ """SocialsForAI: post, schedule and measure on social networks from Python and AI agents.
2
+
3
+ from socialsforai import SocialsForAI
4
+ socials = SocialsForAI() # reads SOCIALSFORAI_API_KEY
5
+ socials.posts.create(["linkedin", "bluesky"], "We just shipped webhooks.", dry_run=True)
6
+
7
+ Docs: https://socialsforai.com/docs · Agent guide: https://socialsforai.com/llms.txt
8
+ """
9
+
10
+ from ._client import DEFAULT_BASE_URL, VERSION, SocialsError, SocialsForAI, verify_webhook
11
+
12
+ __all__ = ["SocialsForAI", "SocialsError", "verify_webhook", "DEFAULT_BASE_URL", "VERSION"]
13
+ __version__ = VERSION
@@ -0,0 +1,335 @@
1
+ """The HTTP client. Standard library only, so it installs anywhere an agent runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import os
9
+ import time
10
+ import uuid
11
+ from typing import Any, Mapping
12
+ from urllib import error as urlerror
13
+ from urllib import parse, request
14
+
15
+ DEFAULT_BASE_URL = "https://socialsforai.com"
16
+ VERSION = "0.1.0"
17
+
18
+
19
+ class SocialsError(Exception):
20
+ """An error the API returned. `code` is stable, `fix` says what to do, `issues` lists failed checks."""
21
+
22
+ def __init__(self, status: int, code: str | None, message: str, fix: str | None = None, issues: list[dict] | None = None, request_id: str | None = None):
23
+ super().__init__(message)
24
+ self.status = status
25
+ self.code = code or "http_error"
26
+ self.message = message
27
+ self.fix = fix
28
+ self.issues = issues or []
29
+ self.request_id = request_id
30
+
31
+ def to_dict(self) -> dict:
32
+ """The error as the API sent it, for handing back to a model."""
33
+ out: dict[str, Any] = {"code": self.code, "message": self.message}
34
+ if self.fix:
35
+ out["fix"] = self.fix
36
+ if self.issues:
37
+ out["issues"] = self.issues
38
+ if self.request_id:
39
+ out["request_id"] = self.request_id
40
+ return {"error": out}
41
+
42
+ def __str__(self) -> str:
43
+ return f"{self.code}: {self.message}" + (f" Fix: {self.fix}" if self.fix else "")
44
+
45
+
46
+ def _query(params: Mapping[str, Any] | None) -> str:
47
+ if not params:
48
+ return ""
49
+ pairs = []
50
+ for k, v in params.items():
51
+ if v is None:
52
+ continue
53
+ if isinstance(v, bool):
54
+ v = "true" if v else "false"
55
+ elif isinstance(v, (list, tuple)):
56
+ v = ",".join(str(x) for x in v)
57
+ pairs.append((k, str(v)))
58
+ return ("?" + parse.urlencode(pairs)) if pairs else ""
59
+
60
+
61
+ def _quote(s: str) -> str:
62
+ return parse.quote(str(s), safe="")
63
+
64
+
65
+ class _Group:
66
+ def __init__(self, client: "SocialsForAI"):
67
+ self._c = client
68
+
69
+
70
+ class Workspaces(_Group):
71
+ def create(self, name: str | None = None, email: str | None = None) -> dict:
72
+ """Create a workspace and get an API key. Needs no key. The client starts using the new key."""
73
+ body = {k: v for k, v in {"name": name, "email": email}.items() if v}
74
+ ws = self._c.request("POST", "/v1/workspaces", body=body, auth=False)
75
+ if ws.get("api_key") and not self._c.api_key:
76
+ self._c.api_key = ws["api_key"]
77
+ return ws
78
+
79
+
80
+ class Workspace(_Group):
81
+ def get(self) -> dict:
82
+ return self._c.request("GET", "/v1/workspace")
83
+
84
+ def set_owner_email(self, email: str) -> dict:
85
+ """Attach the owner's email (only while none is set): dashboard sign-in, recovery, paying for a plan."""
86
+ return self._c.request("PATCH", "/v1/workspace", body={"email": email})
87
+
88
+
89
+ class Accounts(_Group):
90
+ def list(self, platform: str | None = None, external_ref: str | None = None, status: str | None = None) -> list[dict]:
91
+ return self._c.request("GET", "/v1/accounts" + _query({"platform": platform, "external_ref": external_ref, "status": status}))["data"]
92
+
93
+ def get(self, id: str) -> dict:
94
+ return self._c.request("GET", f"/v1/accounts/{_quote(id)}")
95
+
96
+ def connect(self, platform: str, credentials: Mapping[str, str], external_ref: str | None = None) -> dict:
97
+ """Connect Bluesky, Mastodon, Telegram, Discord, Slack or Nostr with credentials the owner gave you (or "sandbox" with {})."""
98
+ body: dict[str, Any] = {"platform": platform, "credentials": dict(credentials)}
99
+ if external_ref:
100
+ body["external_ref"] = external_ref
101
+ return self._c.request("POST", "/v1/accounts", body=body)
102
+
103
+ def disconnect(self, id: str) -> dict:
104
+ return self._c.request("DELETE", f"/v1/accounts/{_quote(id)}")
105
+
106
+
107
+ class ConnectLinks(_Group):
108
+ def create(self, platforms: list[str] | None = None, external_ref: str | None = None, redirect_url: str | None = None, expires_in: int | None = None) -> dict:
109
+ body = {k: v for k, v in {"platforms": platforms, "external_ref": external_ref, "redirect_url": redirect_url, "expires_in": expires_in}.items() if v is not None}
110
+ return self._c.request("POST", "/v1/connect-links", body=body)
111
+
112
+ def get(self, id: str, wait_seconds: int | None = None, until: str | None = None) -> dict:
113
+ """The link's status. With wait_seconds (max 120) it holds until the owner finishes."""
114
+ return self._c.request("GET", f"/v1/connect-links/{_quote(id)}" + _query({"wait": wait_seconds, "until": until}))
115
+
116
+ def wait_for(self, id: str, until: str = "all", timeout: float = 900) -> dict:
117
+ """Keep waiting until the owner has connected (all networks, or any with until="any") or the link expired."""
118
+ deadline = time.monotonic() + timeout
119
+ while True:
120
+ s = self.get(id, wait_seconds=60, until=until)
121
+ if s.get("status") in ("connected", "expired") or (until == "any" and s.get("status") == "partially_connected") or time.monotonic() >= deadline:
122
+ return s
123
+
124
+
125
+ class Posts(_Group):
126
+ def create(self, accounts: list[str], text: str = "", *, idempotency_key: str | None = None, **fields: Any) -> dict:
127
+ """Publish now or schedule. Any API field works as a keyword: media, link, thread, auto_thread, poll,
128
+ scheduled_at ("best" too), timezone, repeat, reply_to, reply_to_comment, first_comment, platform_options,
129
+ approval="required", dry_run=True, wait=True. An idempotency key is generated when you don't pass one,
130
+ so retries never publish twice; pass your own to make your own retries safe too."""
131
+ body = {"accounts": accounts, "text": text, **fields}
132
+ key = idempotency_key or body.pop("idempotency_key", None) or (None if body.get("dry_run") else str(uuid.uuid4()))
133
+ return self._c.request("POST", "/v1/posts", body=body, headers={"Idempotency-Key": key} if key else None)
134
+
135
+ def create_many(self, posts: list[dict], *, dry_run: bool = False, idempotency_key: str | None = None) -> dict:
136
+ """Up to 50 posts in one request (a content calendar). Each is checked and created by itself."""
137
+ key = idempotency_key or (None if dry_run else str(uuid.uuid4()))
138
+ return self._c.request("POST", "/v1/posts/batch", body={"posts": posts, "dry_run": dry_run}, headers={"Idempotency-Key": key} if key else None)
139
+
140
+ def list(self, status: str | None = None, series_id: str | None = None, limit: int | None = None, before: str | None = None) -> dict:
141
+ return self._c.request("GET", "/v1/posts" + _query({"status": status, "series_id": series_id, "limit": limit, "before": before}))
142
+
143
+ def get(self, id: str) -> dict:
144
+ return self._c.request("GET", f"/v1/posts/{_quote(id)}")
145
+
146
+ def update(self, id: str, **fields: Any) -> dict:
147
+ """Edit a post that hasn't started publishing. Fields left out stay; scheduled_at=None publishes now."""
148
+ return self._c.request("PATCH", f"/v1/posts/{_quote(id)}", body=fields)
149
+
150
+ def delete(self, id: str, accounts: list[str] | None = None, series: bool = False) -> dict:
151
+ """Delete it everywhere it went out (or only from some accounts). series=True cancels the rest of a repeat series."""
152
+ return self._c.request("DELETE", f"/v1/posts/{_quote(id)}" + _query({"accounts": accounts, "series": True if series else None}))
153
+
154
+ def metrics(self, id: str) -> dict:
155
+ return self._c.request("GET", f"/v1/posts/{_quote(id)}/metrics")
156
+
157
+ def replies(self, id: str) -> dict:
158
+ """What people replied under each published copy. Answer one with create(..., reply_to=id, reply_to_comment=reply["id"])."""
159
+ return self._c.request("GET", f"/v1/posts/{_quote(id)}/replies")
160
+
161
+ def wait_for_approval(self, id: str, timeout: float = 3600) -> dict:
162
+ """Wait while the post is pending_approval; returns it once a person decides (or the timeout passes)."""
163
+ deadline = time.monotonic() + timeout
164
+ while True:
165
+ p = self._c.request("GET", f"/v1/posts/{_quote(id)}?wait=60")
166
+ if p.get("status") != "pending_approval" or time.monotonic() >= deadline:
167
+ return p
168
+
169
+
170
+ class Media(_Group):
171
+ def upload(self, data: bytes, content_type: str) -> dict:
172
+ """Upload a file of up to 4 MB and get a url to use in posts."""
173
+ return self._c.request("POST", "/v1/media", raw=data, headers={"Content-Type": content_type})
174
+
175
+ def upload_file(self, path: str, content_type: str | None = None) -> dict:
176
+ """Upload a local file of any supported size; returns {url}."""
177
+ import mimetypes
178
+
179
+ ctype = content_type or mimetypes.guess_type(path)[0]
180
+ if not ctype:
181
+ raise ValueError(f"Can't tell the type of {path}; pass content_type.")
182
+ with open(path, "rb") as f:
183
+ data = f.read()
184
+ if len(data) <= 4 * 1024 * 1024:
185
+ return self.upload(data, ctype)
186
+ up = self._c.request("POST", "/v1/media/uploads", body={"content_type": ctype, "size_bytes": len(data)})
187
+ req = request.Request(up["upload_url"], data=data, method=up.get("method", "PUT"), headers=up.get("headers") or {"Content-Type": ctype})
188
+ with request.urlopen(req, timeout=self._c.timeout) as res:
189
+ if res.status >= 300:
190
+ raise SocialsError(res.status, "upload_failed", f"The storage upload answered {res.status}.", "Upload again for a fresh URL.")
191
+ return {"url": up["url"]}
192
+
193
+
194
+ class Events(_Group):
195
+ def list(self, after: str | None = None, types: list[str] | None = None, limit: int | None = None, wait_seconds: int | None = None) -> dict:
196
+ """What happened since `after` (an event id), oldest first. Keep last_id for the next call."""
197
+ return self._c.request("GET", "/v1/events" + _query({"after": after, "types": types, "limit": limit, "wait": wait_seconds}))
198
+
199
+
200
+ class Webhooks(_Group):
201
+ def list(self) -> list[dict]:
202
+ return self._c.request("GET", "/v1/webhooks")["data"]
203
+
204
+ def create(self, url: str, events: list[str] | None = None) -> dict:
205
+ return self._c.request("POST", "/v1/webhooks", body={"url": url, "events": events or ["*"]})
206
+
207
+ def delete(self, id: str) -> dict:
208
+ return self._c.request("DELETE", f"/v1/webhooks/{_quote(id)}")
209
+
210
+
211
+ class Keys(_Group):
212
+ def list(self) -> list[dict]:
213
+ return self._c.request("GET", "/v1/keys")["data"]
214
+
215
+ def create(self, **fields: Any) -> dict:
216
+ """name, scopes, account_ids, require_approval, daily_post_limit. The key is returned once."""
217
+ return self._c.request("POST", "/v1/keys", body=fields)
218
+
219
+ def revoke(self, id: str) -> dict:
220
+ return self._c.request("DELETE", f"/v1/keys/{_quote(id)}")
221
+
222
+
223
+ class SocialsForAI:
224
+ """Client for the SocialsForAI API.
225
+
226
+ from socialsforai import SocialsForAI
227
+ socials = SocialsForAI() # reads SOCIALSFORAI_API_KEY
228
+ post = socials.posts.create(["linkedin"], "Hello", wait=True)
229
+ """
230
+
231
+ def __init__(self, api_key: str | None = None, *, base_url: str | None = None, max_retries: int = 2, timeout: float = 330):
232
+ self.api_key = api_key or os.environ.get("SOCIALSFORAI_API_KEY") or os.environ.get("SOCIALS_API_KEY")
233
+ self.base_url = (base_url or os.environ.get("SOCIALSFORAI_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
234
+ self.max_retries = max_retries
235
+ self.timeout = timeout
236
+ #: The rate limit as of the last response: {"limit", "remaining", "reset"}, or None.
237
+ self.rate_limit: dict | None = None
238
+ self.workspaces = Workspaces(self)
239
+ self.workspace = Workspace(self)
240
+ self.accounts = Accounts(self)
241
+ self.connect_links = ConnectLinks(self)
242
+ self.posts = Posts(self)
243
+ self.media = Media(self)
244
+ self.events = Events(self)
245
+ self.webhooks = Webhooks(self)
246
+ self.keys = Keys(self)
247
+
248
+ # One-call groups
249
+ def platforms(self) -> list[dict]:
250
+ return self.request("GET", "/v1/platforms", auth=False)["data"]
251
+
252
+ def usage(self) -> dict:
253
+ return self.request("GET", "/v1/usage")
254
+
255
+ def analytics(self, **params: Any) -> dict:
256
+ return self.request("GET", "/v1/analytics" + _query(params))
257
+
258
+ def health(self) -> dict:
259
+ return self.request("GET", "/v1/health", auth=False, ok_statuses=(503,))
260
+
261
+ def upgrade_link(self, plan: str) -> dict:
262
+ """A payment link for a human: you can't pay, the workspace owner can."""
263
+ return self.request("POST", "/v1/upgrade-links", body={"plan": plan})
264
+
265
+ def request(self, method: str, path: str, *, body: Any = None, raw: bytes | None = None, headers: Mapping[str, str] | None = None, auth: bool = True, ok_statuses: tuple[int, ...] = ()) -> Any:
266
+ """Low-level request. Retries 429, 5xx and network errors (honouring Retry-After); never retries other errors."""
267
+ if auth and not self.api_key:
268
+ raise SocialsError(0, "missing_api_key", "No API key.", "Pass api_key or set SOCIALSFORAI_API_KEY. Get one with socials.workspaces.create().")
269
+ h = {"Accept": "application/json", "User-Agent": f"socialsforai-sdk-python/{VERSION}"}
270
+ if auth:
271
+ h["Authorization"] = f"Bearer {self.api_key}"
272
+ data = raw
273
+ if body is not None and raw is None:
274
+ data = json.dumps(body).encode()
275
+ h["Content-Type"] = "application/json"
276
+ h.update(headers or {})
277
+ attempt = 0
278
+ while True:
279
+ req = request.Request(self.base_url + path, data=data, method=method, headers=h)
280
+ try:
281
+ with request.urlopen(req, timeout=self.timeout) as res:
282
+ return self._finish(res.status, res.headers, res.read())
283
+ except urlerror.HTTPError as e:
284
+ status, hdrs, payload = e.code, e.headers, e.read()
285
+ if status in ok_statuses:
286
+ return self._finish(status, hdrs, payload)
287
+ if (status == 429 or status >= 500) and attempt < self.max_retries:
288
+ after = hdrs.get("Retry-After")
289
+ time.sleep(min(float(after), 60) if after and after.replace(".", "", 1).isdigit() else 0.5 * 2**attempt)
290
+ attempt += 1
291
+ continue
292
+ self._note_rate(hdrs)
293
+ err = {}
294
+ try:
295
+ err = json.loads(payload or b"{}").get("error") or {}
296
+ except ValueError:
297
+ pass
298
+ raise SocialsError(status, err.get("code"), err.get("message") or f"HTTP {status}", err.get("fix"), err.get("issues"), err.get("request_id") or hdrs.get("Request-Id")) from None
299
+ except (urlerror.URLError, TimeoutError, ConnectionError) as e:
300
+ if attempt < self.max_retries:
301
+ time.sleep(0.5 * 2**attempt)
302
+ attempt += 1
303
+ continue
304
+ reason = getattr(e, "reason", e)
305
+ raise SocialsError(0, "network_error", f"Could not reach {self.base_url}: {reason}", "Check the network and retry; creating a post is safe to retry with the same idempotency key.") from None
306
+
307
+ def _note_rate(self, headers: Any) -> None:
308
+ limit = headers.get("RateLimit-Limit") if headers else None
309
+ if limit:
310
+ self.rate_limit = {"limit": int(limit), "remaining": int(headers.get("RateLimit-Remaining") or 0), "reset": int(headers.get("RateLimit-Reset") or 0)}
311
+
312
+ def _finish(self, status: int, headers: Any, payload: bytes) -> Any:
313
+ self._note_rate(headers)
314
+ if not payload:
315
+ return None
316
+ try:
317
+ return json.loads(payload)
318
+ except ValueError:
319
+ return None
320
+
321
+
322
+ def verify_webhook(secret: str, header: str | None, raw_body: bytes | str, *, tolerance_seconds: int = 300, now: float | None = None) -> bool:
323
+ """Check a delivery's Socials-Signature header ("t=<unix>,v1=<hex>"): HMAC-SHA256 of "<t>.<raw body>".
324
+ Pass the raw request body as received, before parsing it."""
325
+ parts = dict(p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p)
326
+ try:
327
+ t = int(parts["t"])
328
+ v1 = parts["v1"]
329
+ except (KeyError, ValueError):
330
+ return False
331
+ if abs((now if now is not None else time.time()) - t) > tolerance_seconds:
332
+ return False
333
+ body = raw_body.encode() if isinstance(raw_body, str) else raw_body
334
+ expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
335
+ return hmac.compare_digest(expected, v1)
@@ -0,0 +1,2 @@
1
+ """Adapters that turn socialsforai.tools into tools for agent frameworks. Each imports its framework
2
+ only when used, so installing socialsforai never pulls them in."""
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any, Iterable
5
+
6
+ from .._client import SocialsForAI
7
+ from ..tools import TOOLS
8
+
9
+
10
+ def pick(names: Iterable[str] | None, include_destructive: bool) -> list[dict[str, Any]]:
11
+ """The tool specs to expose: all, or the named ones. Destructive tools (delete_post) need opting in."""
12
+ wanted = set(names) if names is not None else None
13
+ out = [t for t in TOOLS if (wanted is None or t["name"] in wanted) and (include_destructive or not t.get("destructive") or (wanted is not None and t["name"] in wanted))]
14
+ if wanted is not None:
15
+ missing = wanted - {t["name"] for t in TOOLS}
16
+ if missing:
17
+ raise ValueError(f"Unknown tools: {', '.join(sorted(missing))}. Available: {', '.join(t['name'] for t in TOOLS)}.")
18
+ return out
19
+
20
+
21
+ def client_or_default(client: SocialsForAI | None) -> SocialsForAI:
22
+ return client or SocialsForAI()
23
+
24
+
25
+ def as_text(result: dict[str, Any]) -> str:
26
+ return json.dumps(result, ensure_ascii=False)
@@ -0,0 +1,48 @@
1
+ """CrewAI tools.
2
+
3
+ from socialsforai.integrations.crewai import socials_tools
4
+ agent = Agent(role="Social media manager", tools=socials_tools(), ...)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Iterable
10
+
11
+ from .._client import SocialsForAI
12
+ from ..tools import run_tool
13
+ from ._common import as_text, client_or_default, pick
14
+
15
+
16
+ def _model_for(spec: dict[str, Any]):
17
+ """A pydantic model for the tool's input (CrewAI validates arguments with it)."""
18
+ from pydantic import ConfigDict, Field, create_model
19
+
20
+ types = {"string": str, "integer": int, "boolean": bool, "array": list, "object": dict}
21
+ props = spec["input"].get("properties", {})
22
+ required = set(spec["input"].get("required", []))
23
+ fields = {}
24
+ for name, p in props.items():
25
+ t = types.get(p.get("type"), Any)
26
+ fields[name] = (t, Field(... if name in required else None, description=p.get("description", "")))
27
+ return create_model(f"{spec['name']}_input", __config__=ConfigDict(extra="allow"), **fields)
28
+
29
+
30
+ def socials_tools(client: SocialsForAI | None = None, *, tools: Iterable[str] | None = None, include_destructive: bool = False) -> list:
31
+ try:
32
+ from crewai.tools import BaseTool
33
+ except ImportError as e: # pragma: no cover
34
+ raise ImportError("Install CrewAI first: pip install crewai") from e
35
+ c = client_or_default(client)
36
+ out = []
37
+ for spec in pick(tools, include_destructive):
38
+
39
+ class SocialsTool(BaseTool):
40
+ name: str = spec["name"]
41
+ description: str = spec["description"]
42
+ args_schema: type = _model_for(spec)
43
+
44
+ def _run(self, **kwargs) -> str:
45
+ return as_text(run_tool(c, self.name, {k: v for k, v in kwargs.items() if v is not None}))
46
+
47
+ out.append(SocialsTool())
48
+ return out
@@ -0,0 +1,30 @@
1
+ """LangChain tools (also usable from LangGraph).
2
+
3
+ from socialsforai.integrations.langchain import socials_tools
4
+ tools = socials_tools() # reads SOCIALSFORAI_API_KEY
5
+ agent = create_react_agent(model, tools)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Iterable
11
+
12
+ from .._client import SocialsForAI
13
+ from ..tools import run_tool
14
+ from ._common import as_text, client_or_default, pick
15
+
16
+
17
+ def socials_tools(client: SocialsForAI | None = None, *, tools: Iterable[str] | None = None, include_destructive: bool = False) -> list:
18
+ try:
19
+ from langchain_core.tools import StructuredTool
20
+ except ImportError as e: # pragma: no cover
21
+ raise ImportError("Install LangChain first: pip install langchain-core") from e
22
+ c = client_or_default(client)
23
+ out = []
24
+ for spec in pick(tools, include_destructive):
25
+
26
+ def call(_name: str = spec["name"], **kwargs) -> str:
27
+ return as_text(run_tool(c, _name, kwargs))
28
+
29
+ out.append(StructuredTool.from_function(func=call, name=spec["name"], description=spec["description"], args_schema=spec["input"]))
30
+ return out
@@ -0,0 +1,35 @@
1
+ """Tools for the OpenAI Agents SDK.
2
+
3
+ from agents import Agent
4
+ from socialsforai.integrations.openai_agents import socials_tools
5
+ agent = Agent(name="Social media", tools=socials_tools())
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Iterable
12
+
13
+ from .._client import SocialsForAI
14
+ from ..tools import run_tool
15
+ from ._common import as_text, client_or_default, pick
16
+
17
+
18
+ def socials_tools(client: SocialsForAI | None = None, *, tools: Iterable[str] | None = None, include_destructive: bool = False) -> list:
19
+ try:
20
+ from agents import FunctionTool
21
+ except ImportError as e: # pragma: no cover
22
+ raise ImportError("Install the OpenAI Agents SDK first: pip install openai-agents") from e
23
+ c = client_or_default(client)
24
+ out = []
25
+ for spec in pick(tools, include_destructive):
26
+
27
+ async def invoke(_ctx, raw: str, _name: str = spec["name"]) -> str:
28
+ import asyncio
29
+
30
+ args = json.loads(raw or "{}")
31
+ return as_text(await asyncio.to_thread(run_tool, c, _name, args))
32
+
33
+ # Our schemas have optional fields, so strict mode (which requires every field) is off.
34
+ out.append(FunctionTool(name=spec["name"], description=spec["description"], params_json_schema=spec["input"], on_invoke_tool=invoke, strict_json_schema=False))
35
+ return out
socialsforai/py.typed ADDED
File without changes
@@ -0,0 +1,566 @@
1
+ {
2
+ "version": 1,
3
+ "tools": [
4
+ {
5
+ "name": "list_accounts",
6
+ "description": "List the social accounts connected to this workspace. Use their ids in create_post. If the one you need is missing, call create_connect_link.",
7
+ "request": {
8
+ "method": "GET",
9
+ "path": "/v1/accounts",
10
+ "query": [
11
+ "platform"
12
+ ]
13
+ },
14
+ "input": {
15
+ "type": "object",
16
+ "properties": {
17
+ "platform": {
18
+ "type": "string",
19
+ "description": "Only accounts on this network, e.g. \"x\"."
20
+ }
21
+ }
22
+ },
23
+ "readOnly": true
24
+ },
25
+ {
26
+ "name": "list_platforms",
27
+ "description": "Which networks are available and what each supports: character and media limits, threads, polls, delete, metrics.",
28
+ "request": {
29
+ "method": "GET",
30
+ "path": "/v1/platforms"
31
+ },
32
+ "input": {
33
+ "type": "object",
34
+ "properties": {}
35
+ },
36
+ "readOnly": true
37
+ },
38
+ {
39
+ "name": "create_connect_link",
40
+ "description": "Create a link the account owner opens to connect social accounts (they approve on the network's own page). Give them the url, then check list_accounts.",
41
+ "request": {
42
+ "method": "POST",
43
+ "path": "/v1/connect-links",
44
+ "body": [
45
+ "platforms",
46
+ "external_ref"
47
+ ]
48
+ },
49
+ "input": {
50
+ "type": "object",
51
+ "properties": {
52
+ "platforms": {
53
+ "type": "array",
54
+ "items": {
55
+ "type": "string"
56
+ },
57
+ "description": "Networks to offer, e.g. [\"x\", \"linkedin\"]. Omit for all."
58
+ },
59
+ "external_ref": {
60
+ "type": "string",
61
+ "description": "Your label for whose accounts these are."
62
+ }
63
+ }
64
+ }
65
+ },
66
+ {
67
+ "name": "create_post",
68
+ "description": "Publish or schedule a post to one or more accounts and get one result per account. Run it with dry_run: true first and show the person the text per network. Every error includes a fix: act on it. Reuse idempotency_key when retrying.",
69
+ "request": {
70
+ "method": "POST",
71
+ "path": "/v1/posts",
72
+ "body": "*",
73
+ "defaults": {
74
+ "wait": true
75
+ },
76
+ "idempotency": true
77
+ },
78
+ "input": {
79
+ "type": "object",
80
+ "properties": {
81
+ "accounts": {
82
+ "type": "array",
83
+ "items": {
84
+ "type": "string"
85
+ },
86
+ "description": "Account ids (acc_...) from list_accounts, or a network name like \"linkedin\" when there is exactly one account on it."
87
+ },
88
+ "text": {
89
+ "type": "string",
90
+ "description": "The post's text."
91
+ },
92
+ "media": {
93
+ "type": "array",
94
+ "description": "Images, videos or a document, by public URL. The URL must be the file itself, not a share page.",
95
+ "items": {
96
+ "type": "object",
97
+ "properties": {
98
+ "url": {
99
+ "type": "string",
100
+ "description": "Public URL of the file."
101
+ },
102
+ "type": {
103
+ "type": "string",
104
+ "enum": [
105
+ "image",
106
+ "video",
107
+ "document"
108
+ ]
109
+ },
110
+ "alt": {
111
+ "type": "string",
112
+ "description": "Alt text."
113
+ }
114
+ },
115
+ "required": [
116
+ "url"
117
+ ]
118
+ }
119
+ },
120
+ "link": {
121
+ "type": "object",
122
+ "description": "Share a URL as a preview card.",
123
+ "properties": {
124
+ "url": {
125
+ "type": "string",
126
+ "description": "The page to link to."
127
+ }
128
+ },
129
+ "required": [
130
+ "url"
131
+ ]
132
+ },
133
+ "thread": {
134
+ "type": "array",
135
+ "description": "Follow-up posts, each a reply to the one before (X, Bluesky, Mastodon, Telegram, Nostr).",
136
+ "items": {
137
+ "type": "object",
138
+ "properties": {
139
+ "text": {
140
+ "type": "string"
141
+ }
142
+ },
143
+ "required": [
144
+ "text"
145
+ ]
146
+ }
147
+ },
148
+ "auto_thread": {
149
+ "type": "boolean",
150
+ "description": "Split text that is over a network's limit into a thread there instead of failing."
151
+ },
152
+ "scheduled_at": {
153
+ "type": "string",
154
+ "description": "When to publish: ISO-8601 with an offset, a local time together with timezone, or \"best\" for the hour that has worked best. Omit to publish now."
155
+ },
156
+ "timezone": {
157
+ "type": "string",
158
+ "description": "IANA time zone for a local scheduled_at, e.g. \"America/New_York\"."
159
+ },
160
+ "repeat": {
161
+ "type": "object",
162
+ "description": "Repeat the post: count posts, every day, week or month, at the same local time.",
163
+ "properties": {
164
+ "every": {
165
+ "type": "string",
166
+ "enum": [
167
+ "day",
168
+ "week",
169
+ "month"
170
+ ]
171
+ },
172
+ "count": {
173
+ "type": "integer",
174
+ "minimum": 2,
175
+ "maximum": 52
176
+ },
177
+ "interval": {
178
+ "type": "integer",
179
+ "minimum": 1
180
+ }
181
+ },
182
+ "required": [
183
+ "every",
184
+ "count"
185
+ ]
186
+ },
187
+ "reply_to": {
188
+ "type": "string",
189
+ "description": "A post id (post_...) to reply to, on the same accounts."
190
+ },
191
+ "reply_to_comment": {
192
+ "type": "string",
193
+ "description": "With reply_to: the id of someone's reply under that post (from list_replies), to answer them. One account only."
194
+ },
195
+ "first_comment": {
196
+ "type": "string",
197
+ "description": "Posted as a comment right after the post (LinkedIn), e.g. to hold a link."
198
+ },
199
+ "platform_options": {
200
+ "type": "object",
201
+ "description": "Per-network settings, e.g. {\"x\": {\"text\": \"shorter version\"}, \"reddit\": {\"subreddit\": \"test\"}}. See https://socialsforai.com/llms.txt."
202
+ },
203
+ "approval": {
204
+ "type": "string",
205
+ "enum": [
206
+ "required"
207
+ ],
208
+ "description": "Hold the post until a person approves it at approval.url."
209
+ },
210
+ "dry_run": {
211
+ "type": "boolean",
212
+ "description": "Check everything without publishing. Returns ok, issues (each with a fix) and the exact text per network."
213
+ },
214
+ "idempotency_key": {
215
+ "type": "string",
216
+ "description": "Send the same key when retrying the same post, so it is never published twice."
217
+ }
218
+ },
219
+ "required": [
220
+ "accounts"
221
+ ]
222
+ }
223
+ },
224
+ {
225
+ "name": "create_posts",
226
+ "description": "Create up to 50 posts in one call, such as a week or month of scheduled content. Each is checked and created by itself; results come back per item.",
227
+ "request": {
228
+ "method": "POST",
229
+ "path": "/v1/posts/batch",
230
+ "body": "*",
231
+ "idempotency": true
232
+ },
233
+ "input": {
234
+ "type": "object",
235
+ "properties": {
236
+ "posts": {
237
+ "type": "array",
238
+ "items": {
239
+ "type": "object",
240
+ "properties": {
241
+ "accounts": {
242
+ "type": "array",
243
+ "items": {
244
+ "type": "string"
245
+ },
246
+ "description": "Account ids (acc_...) from list_accounts, or a network name like \"linkedin\" when there is exactly one account on it."
247
+ },
248
+ "text": {
249
+ "type": "string",
250
+ "description": "The post's text."
251
+ },
252
+ "media": {
253
+ "type": "array",
254
+ "description": "Images, videos or a document, by public URL. The URL must be the file itself, not a share page.",
255
+ "items": {
256
+ "type": "object",
257
+ "properties": {
258
+ "url": {
259
+ "type": "string",
260
+ "description": "Public URL of the file."
261
+ },
262
+ "type": {
263
+ "type": "string",
264
+ "enum": [
265
+ "image",
266
+ "video",
267
+ "document"
268
+ ]
269
+ },
270
+ "alt": {
271
+ "type": "string",
272
+ "description": "Alt text."
273
+ }
274
+ },
275
+ "required": [
276
+ "url"
277
+ ]
278
+ }
279
+ },
280
+ "link": {
281
+ "type": "object",
282
+ "description": "Share a URL as a preview card.",
283
+ "properties": {
284
+ "url": {
285
+ "type": "string",
286
+ "description": "The page to link to."
287
+ }
288
+ },
289
+ "required": [
290
+ "url"
291
+ ]
292
+ },
293
+ "thread": {
294
+ "type": "array",
295
+ "description": "Follow-up posts, each a reply to the one before (X, Bluesky, Mastodon, Telegram, Nostr).",
296
+ "items": {
297
+ "type": "object",
298
+ "properties": {
299
+ "text": {
300
+ "type": "string"
301
+ }
302
+ },
303
+ "required": [
304
+ "text"
305
+ ]
306
+ }
307
+ },
308
+ "auto_thread": {
309
+ "type": "boolean",
310
+ "description": "Split text that is over a network's limit into a thread there instead of failing."
311
+ },
312
+ "scheduled_at": {
313
+ "type": "string",
314
+ "description": "When to publish: ISO-8601 with an offset, a local time together with timezone, or \"best\" for the hour that has worked best. Omit to publish now."
315
+ },
316
+ "timezone": {
317
+ "type": "string",
318
+ "description": "IANA time zone for a local scheduled_at, e.g. \"America/New_York\"."
319
+ },
320
+ "repeat": {
321
+ "type": "object",
322
+ "description": "Repeat the post: count posts, every day, week or month, at the same local time.",
323
+ "properties": {
324
+ "every": {
325
+ "type": "string",
326
+ "enum": [
327
+ "day",
328
+ "week",
329
+ "month"
330
+ ]
331
+ },
332
+ "count": {
333
+ "type": "integer",
334
+ "minimum": 2,
335
+ "maximum": 52
336
+ },
337
+ "interval": {
338
+ "type": "integer",
339
+ "minimum": 1
340
+ }
341
+ },
342
+ "required": [
343
+ "every",
344
+ "count"
345
+ ]
346
+ },
347
+ "reply_to": {
348
+ "type": "string",
349
+ "description": "A post id (post_...) to reply to, on the same accounts."
350
+ },
351
+ "reply_to_comment": {
352
+ "type": "string",
353
+ "description": "With reply_to: the id of someone's reply under that post (from list_replies), to answer them. One account only."
354
+ },
355
+ "first_comment": {
356
+ "type": "string",
357
+ "description": "Posted as a comment right after the post (LinkedIn), e.g. to hold a link."
358
+ },
359
+ "platform_options": {
360
+ "type": "object",
361
+ "description": "Per-network settings, e.g. {\"x\": {\"text\": \"shorter version\"}, \"reddit\": {\"subreddit\": \"test\"}}. See https://socialsforai.com/llms.txt."
362
+ },
363
+ "approval": {
364
+ "type": "string",
365
+ "enum": [
366
+ "required"
367
+ ],
368
+ "description": "Hold the post until a person approves it at approval.url."
369
+ },
370
+ "dry_run": {
371
+ "type": "boolean",
372
+ "description": "Check everything without publishing. Returns ok, issues (each with a fix) and the exact text per network."
373
+ },
374
+ "idempotency_key": {
375
+ "type": "string",
376
+ "description": "Send the same key when retrying the same post, so it is never published twice."
377
+ }
378
+ },
379
+ "required": [
380
+ "accounts"
381
+ ]
382
+ },
383
+ "minItems": 1,
384
+ "maxItems": 50
385
+ },
386
+ "dry_run": {
387
+ "type": "boolean",
388
+ "description": "Check everything without publishing. Returns ok, issues (each with a fix) and the exact text per network."
389
+ }
390
+ },
391
+ "required": [
392
+ "posts"
393
+ ]
394
+ }
395
+ },
396
+ {
397
+ "name": "get_post",
398
+ "description": "A post's status and, per account, its link or error with a fix.",
399
+ "request": {
400
+ "method": "GET",
401
+ "path": "/v1/posts/{id}"
402
+ },
403
+ "input": {
404
+ "type": "object",
405
+ "properties": {
406
+ "id": {
407
+ "type": "string",
408
+ "description": "The post id (post_...)."
409
+ }
410
+ },
411
+ "required": [
412
+ "id"
413
+ ]
414
+ },
415
+ "readOnly": true
416
+ },
417
+ {
418
+ "name": "list_posts",
419
+ "description": "Recent posts, newest first. Filter by status, e.g. scheduled or failed.",
420
+ "request": {
421
+ "method": "GET",
422
+ "path": "/v1/posts",
423
+ "query": [
424
+ "status",
425
+ "limit"
426
+ ]
427
+ },
428
+ "input": {
429
+ "type": "object",
430
+ "properties": {
431
+ "status": {
432
+ "type": "string",
433
+ "enum": [
434
+ "pending_approval",
435
+ "scheduled",
436
+ "publishing",
437
+ "published",
438
+ "partially_published",
439
+ "failed",
440
+ "canceled",
441
+ "deleted"
442
+ ]
443
+ },
444
+ "limit": {
445
+ "type": "integer",
446
+ "minimum": 1,
447
+ "maximum": 100
448
+ }
449
+ }
450
+ },
451
+ "readOnly": true
452
+ },
453
+ {
454
+ "name": "delete_post",
455
+ "description": "Delete a post everywhere it went out, or cancel it if it hasn't. Permanent: confirm with the person first. series: true also cancels the rest of a repeat series.",
456
+ "request": {
457
+ "method": "DELETE",
458
+ "path": "/v1/posts/{id}",
459
+ "query": [
460
+ "accounts",
461
+ "series"
462
+ ]
463
+ },
464
+ "input": {
465
+ "type": "object",
466
+ "properties": {
467
+ "id": {
468
+ "type": "string",
469
+ "description": "The post id."
470
+ },
471
+ "accounts": {
472
+ "type": "array",
473
+ "items": {
474
+ "type": "string"
475
+ },
476
+ "description": "Only delete it from these accounts."
477
+ },
478
+ "series": {
479
+ "type": "boolean"
480
+ }
481
+ },
482
+ "required": [
483
+ "id"
484
+ ]
485
+ },
486
+ "destructive": true
487
+ },
488
+ {
489
+ "name": "get_post_metrics",
490
+ "description": "Likes, reposts, replies, views and more for each published copy of a post, plus totals. Cached 15 minutes; X bills per read, so don't poll it in a loop.",
491
+ "request": {
492
+ "method": "GET",
493
+ "path": "/v1/posts/{id}/metrics"
494
+ },
495
+ "input": {
496
+ "type": "object",
497
+ "properties": {
498
+ "id": {
499
+ "type": "string",
500
+ "description": "The post id."
501
+ }
502
+ },
503
+ "required": [
504
+ "id"
505
+ ]
506
+ },
507
+ "readOnly": true
508
+ },
509
+ {
510
+ "name": "list_replies",
511
+ "description": "What people replied under each published copy of a post. Answer one with create_post (one account, reply_to, reply_to_comment). Show the person your answer first.",
512
+ "request": {
513
+ "method": "GET",
514
+ "path": "/v1/posts/{id}/replies"
515
+ },
516
+ "input": {
517
+ "type": "object",
518
+ "properties": {
519
+ "id": {
520
+ "type": "string",
521
+ "description": "The post id."
522
+ }
523
+ },
524
+ "required": [
525
+ "id"
526
+ ]
527
+ },
528
+ "readOnly": true
529
+ },
530
+ {
531
+ "name": "list_events",
532
+ "description": "What happened since you last looked: posts that failed, approvals, accounts needing reconnection. Pass the last_id from the previous call as after.",
533
+ "request": {
534
+ "method": "GET",
535
+ "path": "/v1/events",
536
+ "query": [
537
+ "after",
538
+ "types",
539
+ "limit"
540
+ ]
541
+ },
542
+ "input": {
543
+ "type": "object",
544
+ "properties": {
545
+ "after": {
546
+ "type": "string",
547
+ "description": "An event id (evt_...)."
548
+ },
549
+ "types": {
550
+ "type": "array",
551
+ "items": {
552
+ "type": "string"
553
+ },
554
+ "description": "Only these event types."
555
+ },
556
+ "limit": {
557
+ "type": "integer",
558
+ "minimum": 1,
559
+ "maximum": 200
560
+ }
561
+ }
562
+ },
563
+ "readOnly": true
564
+ }
565
+ ]
566
+ }
socialsforai/tools.py ADDED
@@ -0,0 +1,56 @@
1
+ """Agent tools, independent of any framework: the definitions (name, description, JSON Schema) and
2
+ one function that runs them. The framework adapters in socialsforai.integrations wrap these.
3
+
4
+ from socialsforai import SocialsForAI
5
+ from socialsforai.tools import TOOLS, run_tool
6
+ result = run_tool(SocialsForAI(), "create_post", {"accounts": ["linkedin"], "text": "Hi", "dry_run": True})
7
+
8
+ Errors come back as {"error": {"code", "message", "fix", ...}} instead of being raised, so the model
9
+ can read the fix and act on it.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import uuid
16
+ from importlib import resources
17
+ from typing import Any
18
+
19
+ from ._client import SocialsError, SocialsForAI, _query, _quote
20
+
21
+ _SPEC = json.loads(resources.files(__package__).joinpath("tools.json").read_text())
22
+ #: Every tool: {"name", "description", "input" (JSON Schema), "request", "readOnly"?, "destructive"?}.
23
+ TOOLS: list[dict[str, Any]] = _SPEC["tools"]
24
+ _BY_NAME = {t["name"]: t for t in TOOLS}
25
+
26
+
27
+ def tool_names(read_only: bool = False, include_destructive: bool = True) -> list[str]:
28
+ return [t["name"] for t in TOOLS if (not read_only or t.get("readOnly")) and (include_destructive or not t.get("destructive"))]
29
+
30
+
31
+ def run_tool(client: SocialsForAI, name: str, args: dict[str, Any] | None = None) -> dict[str, Any]:
32
+ """Run one tool with the model's arguments and return the API's JSON (or its error)."""
33
+ spec = _BY_NAME.get(name)
34
+ if not spec:
35
+ return {"error": {"code": "unknown_tool", "message": f"No tool named {name}."}}
36
+ args = dict(args or {})
37
+ req = spec["request"]
38
+ path = req["path"]
39
+ for key in [k[1:-1] for k in path.split("/") if k.startswith("{")]:
40
+ if key not in args:
41
+ return {"error": {"code": "missing_argument", "message": f"{name} needs {key}."}}
42
+ path = path.replace("{" + key + "}", _quote(args.pop(key)))
43
+ query = {k: args.pop(k) for k in req.get("query", []) if k in args}
44
+ body: Any = None
45
+ if req.get("body") == "*":
46
+ body = {**req.get("defaults", {}), **args}
47
+ elif req.get("body"):
48
+ body = {k: args[k] for k in req["body"] if k in args}
49
+ headers = None
50
+ if req.get("idempotency") and not (body or {}).get("dry_run"):
51
+ headers = {"Idempotency-Key": (body or {}).pop("idempotency_key", None) or str(uuid.uuid4())}
52
+ try:
53
+ out = client.request(req["method"], path + _query(query), body=body, headers=headers, auth=name != "list_platforms")
54
+ return out if isinstance(out, dict) else {"result": out}
55
+ except SocialsError as e:
56
+ return e.to_dict()
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.5
2
+ Name: socialsforai
3
+ Version: 0.1.0
4
+ Summary: Python client and agent tools for SocialsForAI: post to X, LinkedIn, Bluesky, Mastodon, Telegram, Discord and more from code or AI agents.
5
+ Project-URL: Homepage, https://socialsforai.com
6
+ Project-URL: Documentation, https://socialsforai.com/docs
7
+ Project-URL: Source, https://github.com/SocialsForAi/socialsforai
8
+ Author-email: Codehorts LLC <lee@socialsforai.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai-agents,bluesky,crewai,langchain,linkedin,mcp,openai-agents,social-media,social-media-api,twitter
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Internet
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.9
17
+ Provides-Extra: crewai
18
+ Requires-Dist: crewai>=0.80; extra == 'crewai'
19
+ Provides-Extra: langchain
20
+ Requires-Dist: langchain-core>=0.3; extra == 'langchain'
21
+ Provides-Extra: openai-agents
22
+ Requires-Dist: openai-agents>=0.1; extra == 'openai-agents'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # socialsforai
26
+
27
+ Python client and agent tools for [SocialsForAI](https://socialsforai.com): one API to publish, schedule and measure posts on X, LinkedIn, Bluesky, Mastodon, Telegram, Discord, Slack, Nostr and more, built for AI agents.
28
+
29
+ - No dependencies. Python 3.9+.
30
+ - Safe retries: rate limits, server errors and network errors are retried, and every post gets an idempotency key, so a retry never publishes twice.
31
+ - Every error has a stable `code` and a `fix` that says what to do next.
32
+ - Ready-made tools for LangChain, CrewAI and the OpenAI Agents SDK.
33
+
34
+ ```bash
35
+ pip install socialsforai
36
+ ```
37
+
38
+ ## Quickstart
39
+
40
+ ```python
41
+ from socialsforai import SocialsForAI
42
+
43
+ socials = SocialsForAI() # reads SOCIALSFORAI_API_KEY
44
+
45
+ # No key yet? An agent can sign itself up:
46
+ # socials.workspaces.create(name="My agent", email="owner@example.com")
47
+
48
+ # Connect accounts: send this link to the account owner.
49
+ link = socials.connect_links.create(platforms=["x", "linkedin", "bluesky"])
50
+ print(link["url"])
51
+ socials.connect_links.wait_for(link["id"])
52
+
53
+ # Check, then publish. One result per account.
54
+ check = socials.posts.create(["linkedin", "bluesky"], "We just shipped webhooks.", dry_run=True)
55
+ print(check["ok"], check["issues"])
56
+ post = socials.posts.create(["linkedin", "bluesky"], "We just shipped webhooks.", wait=True)
57
+ for t in post["targets"]:
58
+ print(t["platform"], t["status"], t["url"])
59
+ ```
60
+
61
+ Any API field works as a keyword on `posts.create`: `media`, `link`, `thread`, `auto_thread=True` (split long text into a thread), `scheduled_at="best"`, `timezone`, `repeat={"every": "week", "count": 4}`, `approval="required"`, `platform_options`, `reply_to` and `reply_to_comment`. The full list is in the [agent guide](https://socialsforai.com/llms.txt).
62
+
63
+ | Group | Methods |
64
+ | --- | --- |
65
+ | Posts | `posts.create`, `posts.create_many` (up to 50), `posts.list`, `posts.get`, `posts.update`, `posts.delete`, `posts.metrics`, `posts.replies`, `posts.wait_for_approval` |
66
+ | Accounts | `accounts.list`, `accounts.get`, `accounts.connect`, `accounts.disconnect`, `connect_links.create`, `connect_links.get`, `connect_links.wait_for` |
67
+ | Media | `media.upload` (≤4 MB), `media.upload_file` (any size) |
68
+ | Events | `events.list` (what happened since an event id; for when you can't receive webhooks) |
69
+ | Workspace | `workspaces.create`, `workspace.get`, `workspace.set_owner_email`, `usage()`, `analytics()`, `platforms()`, `health()`, `upgrade_link(plan)` |
70
+ | Admin | `keys.list/create/revoke`, `webhooks.list/create/delete`, `verify_webhook(secret, header, raw_body)` |
71
+
72
+ ## Agent tools
73
+
74
+ The same tools work in each framework: `list_accounts`, `list_platforms`, `create_connect_link`, `create_post`, `create_posts`, `get_post`, `list_posts`, `get_post_metrics`, `list_replies`, `list_events`, and `delete_post`. `delete_post` is off unless you pass `include_destructive=True`. Errors come back to the model as `{"error": {"code", "message", "fix"}}` so it can act on the fix.
75
+
76
+ **LangChain / LangGraph** (`pip install "socialsforai[langchain]"`)
77
+
78
+ ```python
79
+ from langgraph.prebuilt import create_react_agent
80
+ from socialsforai.integrations.langchain import socials_tools
81
+
82
+ agent = create_react_agent(model, socials_tools())
83
+ ```
84
+
85
+ **CrewAI** (`pip install "socialsforai[crewai]"`)
86
+
87
+ ```python
88
+ from crewai import Agent
89
+ from socialsforai.integrations.crewai import socials_tools
90
+
91
+ manager = Agent(role="Social media manager", goal="Publish our launch", backstory="...", tools=socials_tools())
92
+ ```
93
+
94
+ **OpenAI Agents SDK** (`pip install "socialsforai[openai-agents]"`)
95
+
96
+ ```python
97
+ from agents import Agent
98
+ from socialsforai.integrations.openai_agents import socials_tools
99
+
100
+ agent = Agent(name="Social media", instructions="Dry-run before publishing.", tools=socials_tools())
101
+ ```
102
+
103
+ **Anything else:** `socialsforai.tools.TOOLS` has each tool's name, description and JSON Schema, and `run_tool(client, name, args)` runs one.
104
+
105
+ Limit what an agent can do with `socials_tools(tools=["list_accounts", "create_post"])`, or give it a scoped key: `socials.keys.create(name="drafts", scopes=["posts:write"], require_approval=True)`.
106
+
107
+ ## Links
108
+
109
+ - Docs: https://socialsforai.com/docs
110
+ - MCP server: https://socialsforai.com/api/mcp
111
+ - JavaScript SDK: `npm install @socialsforai/sdk`
@@ -0,0 +1,14 @@
1
+ socialsforai/__init__.py,sha256=8gdX4MBfhMkMKirF86NICP7sDAqWwijVVOmgSOPV0wA,583
2
+ socialsforai/_client.py,sha256=n-rfPfyIXdKb5ERFiBsaXS4X6o8tl72lld2hACXxuK4,16539
3
+ socialsforai/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ socialsforai/tools.json,sha256=NVd4XYQMklPTz5yrz1yF2GL1cJ7hhbA79WmfR4pJ_GM,18138
5
+ socialsforai/tools.py,sha256=uWUZqyFsDIHryuXn1P6u0Mo0d7qY-pxBrNQznJtuigU,2589
6
+ socialsforai/integrations/__init__.py,sha256=wSJTGYcX7grhGLzIjNCdUNbhKR8VagWhcFECb6clMrM,168
7
+ socialsforai/integrations/_common.py,sha256=6A36vkajfUnADYJoGw6_3rJTCG7cGtd2J3Lb4A02NSE,1018
8
+ socialsforai/integrations/crewai.py,sha256=Y8lzqmmOIT_RCGJCbFtr3lsazMDojNaF_mibZOKVQz8,1814
9
+ socialsforai/integrations/langchain.py,sha256=BmO3n_dfII4SrkqysoWzrY-GPERubPTukAmv1PJLdn8,1128
10
+ socialsforai/integrations/openai_agents.py,sha256=jC-nMiktnRdTvuTAppo1SuuMMLUbp3l9IBKO1gm-8hw,1337
11
+ socialsforai-0.1.0.dist-info/METADATA,sha256=UbjlLLyoF88RDgwxiwqRWCYMuF8COvC7CGnzKigeUIY,5275
12
+ socialsforai-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
13
+ socialsforai-0.1.0.dist-info/licenses/LICENSE,sha256=ssGiy7YfLrCObMMi-0DFnJU4Fqn0zYU4VKN7K9xCqas,1070
14
+ socialsforai-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Codehorts LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.