manifest-api 0.1.0__tar.gz

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,12 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(git add *)",
5
+ "Bash(git commit -m ' *)",
6
+ "Bash(git push *)",
7
+ "Bash(git -C /Users/maxnordstrom/Desktop/semantic-agent-layer status)",
8
+ "Bash(git -C /Users/maxnordstrom/Desktop/semantic-agent-layer diff)",
9
+ "Bash(git *)"
10
+ ]
11
+ }
12
+ }
@@ -0,0 +1,5 @@
1
+ .env
2
+ session.json
3
+ semantic/
4
+ __pycache__/
5
+ *.pyc
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: manifest-api
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the Manifest API — structured action manifests for AI agents
5
+ Project-URL: Homepage, https://omfang.io/docs
6
+ Project-URL: Repository, https://github.com/omfang/manifest-api
7
+ Project-URL: Bug Tracker, https://github.com/omfang/manifest-api/issues
8
+ Author-email: Omfang AB <hello@omfang.io>
9
+ License: MIT
10
+ Keywords: agents,ai,automation,browser,manifest,web
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Internet :: WWW/HTTP
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.9
24
+ Requires-Dist: httpx>=0.27
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
27
+ Requires-Dist: pytest>=8.0; extra == 'dev'
28
+ Requires-Dist: respx>=0.21; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # manifest-api
32
+
33
+ Python SDK for the [Manifest API](https://omfang.io/docs) — extracts structured action manifests from web pages so AI agents know *what they can do*, not just what's on screen.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install manifest-api
39
+ ```
40
+
41
+ ## Quickstart
42
+
43
+ ### Sync
44
+
45
+ ```python
46
+ from manifest_api import ManifestClient
47
+
48
+ client = ManifestClient(api_key="your-key") # or set MANIFEST_API_KEY env var
49
+ manifest = client.get("https://example.com")
50
+
51
+ print(manifest.current_page_state)
52
+ print(manifest.actions)
53
+
54
+ # Convenience helpers
55
+ action = manifest.action("submit-form")
56
+ inputs = manifest.actions_of_type("input")
57
+ required = manifest.required_actions
58
+ ```
59
+
60
+ ### Async
61
+
62
+ ```python
63
+ import asyncio
64
+ from manifest_api import AsyncManifestClient
65
+
66
+ async def main():
67
+ async with AsyncManifestClient(api_key="your-key") as client:
68
+ manifest = await client.get("https://example.com")
69
+ print(manifest.current_page_state)
70
+
71
+ asyncio.run(main())
72
+ ```
73
+
74
+ ## All methods
75
+
76
+ ```python
77
+ # Both ManifestClient and AsyncManifestClient expose:
78
+ manifest = client.get("https://example.com") # POST /manifest → Manifest
79
+ health = client.health() # GET /health → dict
80
+ valid = client.session_valid() # GET /session-status → bool
81
+ ```
82
+
83
+ ## Manifest helpers
84
+
85
+ ```python
86
+ manifest.action("id") # → Action | None
87
+ manifest.actions_of_type("input") # → list[Action]
88
+ manifest.required_actions # → list[Action]
89
+ ```
90
+
91
+ ## Action types
92
+
93
+ `button` · `input` · `textarea` · `select` · `checkbox` · `radio` · `other`
94
+
95
+ ## Error handling
96
+
97
+ ```python
98
+ from manifest_api import AuthenticationError, RateLimitError, APIError
99
+
100
+ try:
101
+ manifest = client.get("https://example.com")
102
+ except AuthenticationError:
103
+ print("Check your API key")
104
+ except RateLimitError:
105
+ print("Slow down — rate limit hit")
106
+ except APIError as e:
107
+ print(f"Server error {e.status_code}")
108
+ ```
109
+
110
+ ## Docs
111
+
112
+ [https://omfang.io/docs](https://omfang.io/docs)
@@ -0,0 +1,82 @@
1
+ # manifest-api
2
+
3
+ Python SDK for the [Manifest API](https://omfang.io/docs) — extracts structured action manifests from web pages so AI agents know *what they can do*, not just what's on screen.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install manifest-api
9
+ ```
10
+
11
+ ## Quickstart
12
+
13
+ ### Sync
14
+
15
+ ```python
16
+ from manifest_api import ManifestClient
17
+
18
+ client = ManifestClient(api_key="your-key") # or set MANIFEST_API_KEY env var
19
+ manifest = client.get("https://example.com")
20
+
21
+ print(manifest.current_page_state)
22
+ print(manifest.actions)
23
+
24
+ # Convenience helpers
25
+ action = manifest.action("submit-form")
26
+ inputs = manifest.actions_of_type("input")
27
+ required = manifest.required_actions
28
+ ```
29
+
30
+ ### Async
31
+
32
+ ```python
33
+ import asyncio
34
+ from manifest_api import AsyncManifestClient
35
+
36
+ async def main():
37
+ async with AsyncManifestClient(api_key="your-key") as client:
38
+ manifest = await client.get("https://example.com")
39
+ print(manifest.current_page_state)
40
+
41
+ asyncio.run(main())
42
+ ```
43
+
44
+ ## All methods
45
+
46
+ ```python
47
+ # Both ManifestClient and AsyncManifestClient expose:
48
+ manifest = client.get("https://example.com") # POST /manifest → Manifest
49
+ health = client.health() # GET /health → dict
50
+ valid = client.session_valid() # GET /session-status → bool
51
+ ```
52
+
53
+ ## Manifest helpers
54
+
55
+ ```python
56
+ manifest.action("id") # → Action | None
57
+ manifest.actions_of_type("input") # → list[Action]
58
+ manifest.required_actions # → list[Action]
59
+ ```
60
+
61
+ ## Action types
62
+
63
+ `button` · `input` · `textarea` · `select` · `checkbox` · `radio` · `other`
64
+
65
+ ## Error handling
66
+
67
+ ```python
68
+ from manifest_api import AuthenticationError, RateLimitError, APIError
69
+
70
+ try:
71
+ manifest = client.get("https://example.com")
72
+ except AuthenticationError:
73
+ print("Check your API key")
74
+ except RateLimitError:
75
+ print("Slow down — rate limit hit")
76
+ except APIError as e:
77
+ print(f"Server error {e.status_code}")
78
+ ```
79
+
80
+ ## Docs
81
+
82
+ [https://omfang.io/docs](https://omfang.io/docs)
@@ -0,0 +1,38 @@
1
+ # semantic-agent-layer
2
+
3
+ ## What this is
4
+ A proof of concept for a semantic UI layer for AI agents. Instead of scraping raw HTML or using vision models to interact with web interfaces, this project extracts structured action manifests from web pages — telling agents *what they can do* on a page, not just what's on it.
5
+
6
+ The long-term vision is infrastructure that makes any webpage agent-readable natively, similar to what HTML did for browsers.
7
+
8
+ ## Status
9
+ Core pipeline is working end to end. Successfully published a post to Omfang AB's LinkedIn page autonomously.
10
+
11
+ ## Completed
12
+ - Define stable JSON schema for action manifests across different sites
13
+ - Accessibility tree extraction via `page.aria_snapshot()`
14
+ - LLM-based translation of raw snapshot to clean JSON action manifest
15
+ - Execution layer: navigate to composer, fill post body, publish as company page
16
+ - Session cookie persistence via `session.py` (login once, skip forever after)
17
+ - Parameterized post text via `post_to_linkedin(text: str)`
18
+ - Single `post_to_linkedin.py` callable by any agent
19
+ - `manifest.py` — `get_action_manifest(url)` works on any URL, returns structured JSON
20
+
21
+ ## Pipeline
22
+ 1. `linkedin_snapshot.py` — captures accessibility tree of LinkedIn feed
23
+ 2. `translate-snapshot.py` — translates snapshot to clean JSON action manifest
24
+ 3. `execute_post.py` — navigates to company composer, types post, publishes
25
+
26
+ ## Stack
27
+ - Python 3.13
28
+ - Playwright (browser automation)
29
+ - Anthropic SDK (snapshot translation)
30
+ - python-dotenv (env vars)
31
+ - Virtual environment: `semantic/`
32
+
33
+ ## Environment
34
+ - API key loaded from `.env` via `python-dotenv`
35
+ - Always activate venv before running: `source semantic/bin/activate`
36
+ - Run scripts from the root `semantic-agent-layer/` folder
37
+
38
+ ## Next steps
@@ -0,0 +1,74 @@
1
+ import hashlib
2
+ import os
3
+ import secrets
4
+
5
+ import psycopg2
6
+ from psycopg2.extras import Json
7
+ from dotenv import load_dotenv
8
+
9
+ load_dotenv()
10
+
11
+
12
+ def _conn():
13
+ return psycopg2.connect(
14
+ host=os.getenv("DB_HOST"),
15
+ port=os.getenv("DB_PORT", 5432),
16
+ dbname=os.getenv("DB_NAME"),
17
+ user=os.getenv("DB_USER"),
18
+ password=os.getenv("DB_PASSWORD"),
19
+ )
20
+
21
+
22
+ def _hash(key: str) -> str:
23
+ return hashlib.sha256(key.encode()).hexdigest()
24
+
25
+
26
+ def verify_api_key(raw_key: str) -> bool:
27
+ with _conn() as conn, conn.cursor() as cur:
28
+ cur.execute(
29
+ "SELECT 1 FROM api_keys WHERE key_hash = %s AND revoked = false",
30
+ (_hash(raw_key),),
31
+ )
32
+ return cur.fetchone() is not None
33
+
34
+
35
+ def create_api_key(email: str) -> str:
36
+ raw = secrets.token_urlsafe(32)
37
+ with _conn() as conn, conn.cursor() as cur:
38
+ cur.execute(
39
+ "INSERT INTO api_keys (key_hash, email) VALUES (%s, %s)",
40
+ (_hash(raw), email),
41
+ )
42
+ return raw
43
+
44
+
45
+ def get_cached_manifest(url: str) -> dict | None:
46
+ ttl = int(os.getenv("CACHE_TTL_HOURS", "6"))
47
+ with _conn() as conn, conn.cursor() as cur:
48
+ cur.execute(
49
+ "SELECT manifest FROM manifest_cache WHERE url = %s AND cached_at > now() - (%s * interval '1 hour')",
50
+ (url, ttl),
51
+ )
52
+ row = cur.fetchone()
53
+ return row[0] if row else None
54
+
55
+
56
+ def check_rate_limit(key_hash: str) -> bool:
57
+ limit = int(os.getenv("RATE_LIMIT_PER_MINUTE", "10"))
58
+ with _conn() as conn, conn.cursor() as cur:
59
+ cur.execute("INSERT INTO request_log (key_hash) VALUES (%s)", (key_hash,))
60
+ cur.execute(
61
+ "SELECT COUNT(*) FROM request_log WHERE key_hash = %s AND requested_at > now() - interval '60 seconds'",
62
+ (key_hash,),
63
+ )
64
+ count = cur.fetchone()[0]
65
+ return count < limit
66
+
67
+
68
+ def save_manifest(url: str, manifest: dict) -> None:
69
+ with _conn() as conn, conn.cursor() as cur:
70
+ cur.execute(
71
+ """INSERT INTO manifest_cache (url, manifest, cached_at) VALUES (%s, %s, now())
72
+ ON CONFLICT (url) DO UPDATE SET manifest = EXCLUDED.manifest, cached_at = now()""",
73
+ (url, Json(manifest)),
74
+ )
@@ -0,0 +1,112 @@
1
+ import json
2
+ import os
3
+ from urllib.parse import urljoin
4
+ import anthropic
5
+ from dotenv import load_dotenv
6
+ from playwright.sync_api import sync_playwright
7
+ from session import browser_context
8
+ from manifest import get_action_manifest
9
+
10
+ load_dotenv()
11
+
12
+ _client = anthropic.Anthropic()
13
+
14
+ BASE_URL = "https://omfang.io"
15
+
16
+ _TEST_DATA = {
17
+ "name": "Agent Test",
18
+ "email": "test@omfang.io",
19
+ "subject": "Automated test from semantic agent layer",
20
+ "message": "This is an automated test from the semantic agent layer",
21
+ }
22
+
23
+ _FILL_PROMPT = """You have a page manifest and data to fill into a contact form.
24
+
25
+ Manifest:
26
+ {manifest}
27
+
28
+ Data to fill:
29
+ {data}
30
+
31
+ Return a JSON array of fill/click instructions that will complete and submit the form.
32
+ Each instruction is one of:
33
+ {{"action": "fill", "label": "<exact field label>", "value": "<value>"}}
34
+ {{"action": "click", "name": "<exact button text>"}}
35
+
36
+ Use the exact label text from the manifest actions. End with a click on the submit button.
37
+ Return only the JSON array, nothing else."""
38
+
39
+
40
+ def _resolve_url(href: str | None) -> str | None:
41
+ if not href:
42
+ return None
43
+ return urljoin(BASE_URL, href)
44
+
45
+
46
+ def _plan_fill(manifest: dict) -> list[dict]:
47
+ prompt = _FILL_PROMPT.format(
48
+ manifest=json.dumps(manifest, indent=2),
49
+ data=json.dumps(_TEST_DATA, indent=2),
50
+ )
51
+ resp = _client.messages.create(
52
+ model="claude-sonnet-4-6",
53
+ max_tokens=500,
54
+ messages=[{"role": "user", "content": prompt}],
55
+ )
56
+ text = resp.content[0].text.strip()
57
+ if text.startswith("```"):
58
+ text = text.split("\n", 1)[1].rsplit("```", 1)[0]
59
+ return json.loads(text)
60
+
61
+
62
+ def fill_contact_form() -> None:
63
+ # Step 1: discover contact page URL
64
+ print("Step 1: fetching homepage manifest...")
65
+ home = get_action_manifest(BASE_URL)
66
+ contact_nav = next(
67
+ n for n in home["navigation"]
68
+ if any(k in (n["label"] + " " + (n["url"] or "")).lower() for k in ("contact", "kontakt"))
69
+ )
70
+ contact_url = _resolve_url(contact_nav["url"])
71
+ print(f" → contact URL: {contact_url}")
72
+
73
+ # Step 2: discover form fields
74
+ print("Step 2: fetching contact page manifest...")
75
+ contact = get_action_manifest(contact_url)
76
+ print(f" → {len(contact['actions'])} actions, current state: {contact['current_page_state']}")
77
+
78
+ # Step 3: plan the fill
79
+ print("Step 3: planning form fill...")
80
+ steps = _plan_fill(contact)
81
+ for s in steps:
82
+ print(f" → {s}")
83
+
84
+ # Step 4: execute
85
+ print("Step 4: executing...")
86
+ _CHROMIUM_PATH = '/usr/bin/chromium-browser'
87
+ _executable_path = _CHROMIUM_PATH if os.path.exists(_CHROMIUM_PATH) else None
88
+
89
+ with sync_playwright() as p:
90
+ browser = p.chromium.launch(
91
+ headless=_executable_path is not None,
92
+ executable_path=_executable_path,
93
+ )
94
+ _, page = browser_context(browser, start_url=contact_url)
95
+ page.wait_for_load_state("domcontentloaded")
96
+ page.wait_for_timeout(2000)
97
+
98
+ for step in steps:
99
+ if step["action"] == "fill":
100
+ page.get_by_label(step["label"], exact=False).fill(step["value"])
101
+ page.wait_for_timeout(300)
102
+ elif step["action"] == "click":
103
+ page.get_by_role("button", name=step["name"], exact=False).click()
104
+
105
+ page.wait_for_timeout(3000)
106
+ browser.close()
107
+
108
+ print("Done.")
109
+
110
+
111
+ if __name__ == "__main__":
112
+ fill_contact_form()
@@ -0,0 +1,33 @@
1
+ import os
2
+ from playwright.sync_api import sync_playwright
3
+ from session import browser_context
4
+
5
+ COMPOSER_URL = "https://www.linkedin.com/company/113130156/admin/feed/posts?share=true"
6
+
7
+ def post_to_linkedin(text: str) -> None:
8
+ _CHROMIUM_PATH = '/usr/bin/chromium-browser'
9
+ _executable_path = _CHROMIUM_PATH if os.path.exists(_CHROMIUM_PATH) else None
10
+
11
+ with sync_playwright() as p:
12
+ browser = p.chromium.launch(
13
+ headless=_executable_path is not None,
14
+ executable_path=_executable_path,
15
+ )
16
+ context, page = browser_context(browser)
17
+
18
+ page.goto(COMPOSER_URL)
19
+ page.wait_for_selector('[contenteditable="true"]', timeout=15000)
20
+ page.wait_for_timeout(2000)
21
+
22
+ page.locator('[contenteditable="true"]').first.click()
23
+ page.keyboard.type(text)
24
+ page.wait_for_timeout(1000)
25
+
26
+ page.get_by_role("button", name="Lägg upp", exact=True).click()
27
+ page.wait_for_timeout(3000)
28
+
29
+ browser.close()
30
+
31
+ if __name__ == "__main__":
32
+ import sys
33
+ post_to_linkedin(sys.argv[1] if len(sys.argv) > 1 else "Test post from semantic agent layer.")
@@ -0,0 +1,108 @@
1
+ import json
2
+ import os
3
+ import anthropic
4
+ from dotenv import load_dotenv
5
+ from playwright.sync_api import sync_playwright
6
+ from session import browser_context
7
+ from db import get_cached_manifest, save_manifest
8
+
9
+ load_dotenv()
10
+
11
+ _client = anthropic.Anthropic()
12
+
13
+ _PROMPT = """You are a semantic UI translator for AI agents.
14
+
15
+ Given the accessibility snapshot of a web page and supplemental DOM context,
16
+ return a JSON action manifest with this structure:
17
+ {{
18
+ "url": "<page url>",
19
+ "authenticated_user": "<name or null>",
20
+ "current_page_state": "<brief description>",
21
+ "actions": [
22
+ {{
23
+ "id": "<slug>",
24
+ "label": "<human label>",
25
+ "type": "<input|textarea|select|button|checkbox|radio|other>",
26
+ "description": "<what it does>",
27
+ "required": true
28
+ }}
29
+ ],
30
+ "navigation": [
31
+ {{"label": "<link text>", "url": "<href or null>"}}
32
+ ]
33
+ }}
34
+
35
+ Rules:
36
+ - authenticated_user: extract from profile links, greeting text, or avatar labels; null if not detectable
37
+ - actions: only interactive things (buttons, form fields, composers) — skip ads, feed content, footers
38
+ - required: set true if the field appears in dom_context.required_fields (matched by placeholder, name, or label)
39
+ - type: use dom_context.input_types to refine the type (e.g. "email", "tel") when available
40
+ - disabled: omit actions for elements in dom_context.disabled_elements
41
+ - navigation: primary nav links only
42
+ - Return valid JSON only, nothing else
43
+
44
+ Snapshot:
45
+ {snapshot}
46
+
47
+ DOM context:
48
+ {dom_context}"""
49
+
50
+
51
+ _DOM_EXTRACTORS = {
52
+ "required_fields": """() => Array.from(document.querySelectorAll("input[required], textarea[required], select[required]"))
53
+ .map(el => el.placeholder || el.name || el.id)
54
+ .filter(Boolean)""",
55
+ "placeholders": """() => Array.from(document.querySelectorAll("input[placeholder], textarea[placeholder]"))
56
+ .map(el => el.placeholder)
57
+ .filter(Boolean)""",
58
+ "disabled_elements": """() => Array.from(document.querySelectorAll("input[disabled], button[disabled], select[disabled], textarea[disabled]"))
59
+ .map(el => el.placeholder || el.name || el.id || (el.textContent || '').trim())
60
+ .filter(Boolean)""",
61
+ "input_types": """() => Array.from(document.querySelectorAll("input"))
62
+ .map(el => ({ type: el.type, name: el.name || el.id || el.placeholder }))
63
+ .filter(el => el.name)""",
64
+ }
65
+
66
+
67
+ def get_action_manifest(url: str) -> dict:
68
+ cached = get_cached_manifest(url)
69
+ if cached is not None:
70
+ return cached
71
+
72
+ _CHROMIUM_PATH = '/usr/bin/chromium-browser'
73
+ _executable_path = _CHROMIUM_PATH if os.path.exists(_CHROMIUM_PATH) else None
74
+
75
+ with sync_playwright() as p:
76
+ browser = p.chromium.launch(
77
+ headless=_executable_path is not None,
78
+ executable_path=_executable_path,
79
+ )
80
+ context, page = browser_context(browser, start_url=url)
81
+ page.wait_for_load_state("domcontentloaded")
82
+ page.wait_for_timeout(2000)
83
+ snapshot = page.aria_snapshot()
84
+ dom_context = {key: page.evaluate(js) for key, js in _DOM_EXTRACTORS.items()}
85
+ browser.close()
86
+
87
+ response = _client.messages.create(
88
+ model="claude-sonnet-4-6",
89
+ max_tokens=2000,
90
+ messages=[{"role": "user", "content": _PROMPT.format(
91
+ snapshot=snapshot,
92
+ dom_context=json.dumps(dom_context, indent=2),
93
+ )}],
94
+ )
95
+
96
+ text = response.content[0].text.strip()
97
+ # strip markdown code fences if model wraps output
98
+ if text.startswith("```"):
99
+ text = text.split("\n", 1)[1].rsplit("```", 1)[0]
100
+ manifest = json.loads(text)
101
+ manifest["url"] = url
102
+ save_manifest(url, manifest)
103
+ return manifest
104
+
105
+
106
+ if __name__ == "__main__":
107
+ import pprint
108
+ pprint.pprint(get_action_manifest("https://www.linkedin.com/feed/"))
@@ -0,0 +1,28 @@
1
+ """manifest-api — Python SDK for the Manifest API."""
2
+
3
+ from .client import (
4
+ Action,
5
+ APIError,
6
+ AsyncManifestClient,
7
+ AuthenticationError,
8
+ Manifest,
9
+ ManifestClient,
10
+ ManifestError,
11
+ NavLink,
12
+ RateLimitError,
13
+ )
14
+
15
+ __version__ = "0.1.0"
16
+
17
+ __all__ = [
18
+ "__version__",
19
+ "ManifestClient",
20
+ "AsyncManifestClient",
21
+ "Manifest",
22
+ "Action",
23
+ "NavLink",
24
+ "ManifestError",
25
+ "AuthenticationError",
26
+ "RateLimitError",
27
+ "APIError",
28
+ ]
@@ -0,0 +1,237 @@
1
+ """Manifest API client — sync and async."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+ from typing import List, Optional
8
+
9
+ import httpx
10
+
11
+ _BASE_URL = "https://manifest.omfang.io"
12
+ _DEFAULT_TIMEOUT = 30.0
13
+
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Exceptions
17
+ # ---------------------------------------------------------------------------
18
+
19
+ class ManifestError(Exception):
20
+ """Base exception for all Manifest API errors."""
21
+
22
+
23
+ class AuthenticationError(ManifestError):
24
+ """Missing or invalid API key."""
25
+
26
+
27
+ class RateLimitError(ManifestError):
28
+ """Rate limit exceeded (429)."""
29
+
30
+
31
+ class APIError(ManifestError):
32
+ """Unexpected API error (5xx or other)."""
33
+ def __init__(self, message: str, status_code: int) -> None:
34
+ super().__init__(message)
35
+ self.status_code = status_code
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Data models
40
+ # ---------------------------------------------------------------------------
41
+
42
+ @dataclass
43
+ class Action:
44
+ id: str
45
+ label: str
46
+ type: str
47
+ description: str
48
+ required: bool
49
+
50
+ @classmethod
51
+ def _from_dict(cls, d: dict) -> "Action":
52
+ return cls(
53
+ id=d["id"],
54
+ label=d["label"],
55
+ type=d["type"],
56
+ description=d.get("description", ""),
57
+ required=d.get("required", False),
58
+ )
59
+
60
+
61
+ @dataclass
62
+ class NavLink:
63
+ label: str
64
+ url: str
65
+
66
+ @classmethod
67
+ def _from_dict(cls, d: dict) -> "NavLink":
68
+ return cls(label=d["label"], url=d["url"])
69
+
70
+
71
+ @dataclass
72
+ class Manifest:
73
+ url: str
74
+ authenticated_user: Optional[str]
75
+ current_page_state: str
76
+ actions: List[Action] = field(default_factory=list)
77
+ navigation: List[NavLink] = field(default_factory=list)
78
+
79
+ @classmethod
80
+ def _from_dict(cls, d: dict) -> "Manifest":
81
+ return cls(
82
+ url=d["url"],
83
+ authenticated_user=d.get("authenticated_user"),
84
+ current_page_state=d.get("current_page_state", ""),
85
+ actions=[Action._from_dict(a) for a in d.get("actions", [])],
86
+ navigation=[NavLink._from_dict(n) for n in d.get("navigation", [])],
87
+ )
88
+
89
+ def action(self, id: str) -> Optional[Action]:
90
+ """Return the action with the given id, or None."""
91
+ return next((a for a in self.actions if a.id == id), None)
92
+
93
+ def actions_of_type(self, type: str) -> List[Action]:
94
+ """Return all actions matching the given type."""
95
+ return [a for a in self.actions if a.type == type]
96
+
97
+ @property
98
+ def required_actions(self) -> List[Action]:
99
+ """Return all actions where required=True."""
100
+ return [a for a in self.actions if a.required]
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Shared helpers
105
+ # ---------------------------------------------------------------------------
106
+
107
+ def _resolve_api_key(api_key: Optional[str]) -> str:
108
+ key = api_key or os.environ.get("MANIFEST_API_KEY", "")
109
+ if not key:
110
+ raise AuthenticationError(
111
+ "No API key provided. Pass api_key= or set MANIFEST_API_KEY."
112
+ )
113
+ return key
114
+
115
+
116
+ def _headers(api_key: str) -> dict:
117
+ return {"X-API-Key": api_key, "Content-Type": "application/json"}
118
+
119
+
120
+ def _raise_for_status(response: httpx.Response) -> None:
121
+ if response.status_code == 401:
122
+ raise AuthenticationError("Invalid or missing API key (401).")
123
+ if response.status_code == 429:
124
+ raise RateLimitError("Rate limit exceeded (429). Slow down requests.")
125
+ if response.status_code >= 500:
126
+ raise APIError(
127
+ f"Server error: {response.text}", status_code=response.status_code
128
+ )
129
+ if response.status_code >= 400:
130
+ raise APIError(
131
+ f"Client error {response.status_code}: {response.text}",
132
+ status_code=response.status_code,
133
+ )
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # Sync client
138
+ # ---------------------------------------------------------------------------
139
+
140
+ class ManifestClient:
141
+ """Synchronous Manifest API client."""
142
+
143
+ def __init__(
144
+ self,
145
+ api_key: Optional[str] = None,
146
+ base_url: str = _BASE_URL,
147
+ timeout: float = _DEFAULT_TIMEOUT,
148
+ ) -> None:
149
+ self._api_key = _resolve_api_key(api_key)
150
+ self._base_url = base_url.rstrip("/")
151
+ self._client = httpx.Client(timeout=timeout)
152
+
153
+ def get(self, url: str) -> Manifest:
154
+ """Fetch an action manifest for the given URL."""
155
+ response = self._client.post(
156
+ f"{self._base_url}/manifest",
157
+ json={"url": url},
158
+ headers=_headers(self._api_key),
159
+ )
160
+ _raise_for_status(response)
161
+ return Manifest._from_dict(response.json())
162
+
163
+ def health(self) -> dict:
164
+ """Check API health. Returns the raw JSON response."""
165
+ response = self._client.get(f"{self._base_url}/health")
166
+ _raise_for_status(response)
167
+ return response.json()
168
+
169
+ def session_valid(self) -> bool:
170
+ """Return True if the current session cookie is still valid."""
171
+ response = self._client.get(
172
+ f"{self._base_url}/session-status",
173
+ headers=_headers(self._api_key),
174
+ )
175
+ _raise_for_status(response)
176
+ return response.json().get("valid", False)
177
+
178
+ def close(self) -> None:
179
+ self._client.close()
180
+
181
+ def __enter__(self) -> "ManifestClient":
182
+ return self
183
+
184
+ def __exit__(self, *_: object) -> None:
185
+ self.close()
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # Async client
190
+ # ---------------------------------------------------------------------------
191
+
192
+ class AsyncManifestClient:
193
+ """Asynchronous Manifest API client."""
194
+
195
+ def __init__(
196
+ self,
197
+ api_key: Optional[str] = None,
198
+ base_url: str = _BASE_URL,
199
+ timeout: float = _DEFAULT_TIMEOUT,
200
+ ) -> None:
201
+ self._api_key = _resolve_api_key(api_key)
202
+ self._base_url = base_url.rstrip("/")
203
+ self._client = httpx.AsyncClient(timeout=timeout)
204
+
205
+ async def get(self, url: str) -> Manifest:
206
+ """Fetch an action manifest for the given URL."""
207
+ response = await self._client.post(
208
+ f"{self._base_url}/manifest",
209
+ json={"url": url},
210
+ headers=_headers(self._api_key),
211
+ )
212
+ _raise_for_status(response)
213
+ return Manifest._from_dict(response.json())
214
+
215
+ async def health(self) -> dict:
216
+ """Check API health. Returns the raw JSON response."""
217
+ response = await self._client.get(f"{self._base_url}/health")
218
+ _raise_for_status(response)
219
+ return response.json()
220
+
221
+ async def session_valid(self) -> bool:
222
+ """Return True if the current session cookie is still valid."""
223
+ response = await self._client.get(
224
+ f"{self._base_url}/session-status",
225
+ headers=_headers(self._api_key),
226
+ )
227
+ _raise_for_status(response)
228
+ return response.json().get("valid", False)
229
+
230
+ async def close(self) -> None:
231
+ await self._client.aclose()
232
+
233
+ async def __aenter__(self) -> "AsyncManifestClient":
234
+ return self
235
+
236
+ async def __aexit__(self, *_: object) -> None:
237
+ await self.close()
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "manifest-api"
7
+ version = "0.1.0"
8
+ description = "Python SDK for the Manifest API — structured action manifests for AI agents"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Omfang AB", email = "hello@omfang.io" }]
13
+ keywords = ["ai", "agents", "browser", "automation", "manifest", "web"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Software Development :: Libraries :: Python Modules",
25
+ "Topic :: Internet :: WWW/HTTP",
26
+ "Typing :: Typed",
27
+ ]
28
+ dependencies = ["httpx>=0.27"]
29
+
30
+ [project.optional-dependencies]
31
+ dev = ["pytest>=8.0", "respx>=0.21", "pytest-asyncio>=0.23"]
32
+
33
+ [project.urls]
34
+ Homepage = "https://omfang.io/docs"
35
+ Repository = "https://github.com/omfang/manifest-api"
36
+ "Bug Tracker" = "https://github.com/omfang/manifest-api/issues"
37
+
38
+ [tool.pytest.ini_options]
39
+ asyncio_mode = "auto"
@@ -0,0 +1,6 @@
1
+ anthropic==0.112.0
2
+ psycopg2-binary==2.9.10
3
+ playwright==1.60.0
4
+ python-dotenv==1.2.2
5
+ fastapi==0.138.1
6
+ uvicorn==0.49.0
@@ -0,0 +1,44 @@
1
+ import logging
2
+ import uvicorn
3
+ from fastapi import FastAPI, Header, HTTPException
4
+ from pydantic import BaseModel
5
+ from db import verify_api_key, check_rate_limit, _hash
6
+ from manifest import get_action_manifest
7
+ from session import SessionExpiredError, check_session
8
+
9
+ logger = logging.getLogger(__name__)
10
+ app = FastAPI()
11
+
12
+
13
+ class ManifestRequest(BaseModel):
14
+ url: str
15
+
16
+
17
+ @app.get("/health")
18
+ def health():
19
+ return {"status": "ok"}
20
+
21
+
22
+ @app.get("/session-status")
23
+ def session_status():
24
+ if check_session():
25
+ return {"valid": True}
26
+ return {"valid": False, "message": "Session expired"}
27
+
28
+
29
+ @app.post("/manifest")
30
+ def manifest(req: ManifestRequest, x_api_key: str = Header(default=None)):
31
+ raw_key = x_api_key or ""
32
+ if not verify_api_key(raw_key):
33
+ raise HTTPException(status_code=401, detail="Invalid or missing API key")
34
+ if not check_rate_limit(_hash(raw_key)):
35
+ raise HTTPException(status_code=429, detail="Rate limit exceeded. Max 10 requests per minute.")
36
+ try:
37
+ return get_action_manifest(req.url)
38
+ except SessionExpiredError as e:
39
+ logger.error(str(e))
40
+ raise HTTPException(status_code=503, detail=str(e))
41
+
42
+
43
+ if __name__ == "__main__":
44
+ uvicorn.run(app, host="0.0.0.0", port=8000)
@@ -0,0 +1,51 @@
1
+ import os
2
+ from playwright.sync_api import sync_playwright
3
+
4
+ SESSION_FILE = "session.json"
5
+ _UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
6
+ _CHROMIUM_PATH = "/usr/bin/chromium-browser"
7
+
8
+
9
+ class SessionExpiredError(RuntimeError):
10
+ pass
11
+
12
+
13
+ def browser_context(browser, start_url="https://www.linkedin.com/feed/"):
14
+ """Return (context, page) navigated to start_url, prompting for login if needed."""
15
+ has_session = os.path.exists(SESSION_FILE)
16
+ kwargs = {"user_agent": _UA}
17
+ if has_session:
18
+ kwargs["storage_state"] = SESSION_FILE
19
+
20
+ context = browser.new_context(**kwargs)
21
+ page = context.new_page()
22
+ page.goto(start_url)
23
+
24
+ if not has_session:
25
+ raise RuntimeError(
26
+ f"No session file found at '{SESSION_FILE}'. "
27
+ "Run locally first to log in and generate the session file."
28
+ )
29
+
30
+ if "login" in page.url or "authwall" in page.url:
31
+ raise SessionExpiredError(
32
+ "Session expired — refresh session.json by running session.py locally and re-uploading to the server"
33
+ )
34
+
35
+ return context, page
36
+
37
+
38
+ def check_session() -> bool:
39
+ """Return True if session.json exists and is still valid on LinkedIn."""
40
+ if not os.path.exists(SESSION_FILE):
41
+ return False
42
+ executable_path = _CHROMIUM_PATH if os.path.exists(_CHROMIUM_PATH) else None
43
+ with sync_playwright() as p:
44
+ browser = p.chromium.launch(headless=executable_path is not None, executable_path=executable_path)
45
+ try:
46
+ browser_context(browser)
47
+ return True
48
+ except SessionExpiredError:
49
+ return False
50
+ finally:
51
+ browser.close()
File without changes
@@ -0,0 +1,144 @@
1
+ """Tests for manifest_api client."""
2
+
3
+ import os
4
+ import pytest
5
+ import respx
6
+ import httpx
7
+
8
+ from manifest_api import (
9
+ ManifestClient,
10
+ AsyncManifestClient,
11
+ AuthenticationError,
12
+ RateLimitError,
13
+ APIError,
14
+ )
15
+
16
+ BASE = "https://manifest.omfang.io"
17
+
18
+ MANIFEST_PAYLOAD = {
19
+ "url": "https://example.com",
20
+ "authenticated_user": None,
21
+ "current_page_state": "Landing page",
22
+ "actions": [
23
+ {
24
+ "id": "submit-form",
25
+ "label": "Submit",
26
+ "type": "button",
27
+ "description": "Submits the contact form",
28
+ "required": False,
29
+ },
30
+ {
31
+ "id": "email-input",
32
+ "label": "Email",
33
+ "type": "input",
34
+ "description": "Email address field",
35
+ "required": True,
36
+ },
37
+ ],
38
+ "navigation": [{"label": "Home", "url": "/"}],
39
+ }
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Sync client
44
+ # ---------------------------------------------------------------------------
45
+
46
+ @respx.mock
47
+ def test_successful_manifest_fetch():
48
+ respx.post(f"{BASE}/manifest").mock(return_value=httpx.Response(200, json=MANIFEST_PAYLOAD))
49
+
50
+ client = ManifestClient(api_key="test-key")
51
+ manifest = client.get("https://example.com")
52
+
53
+ assert manifest.url == "https://example.com"
54
+ assert manifest.current_page_state == "Landing page"
55
+ assert len(manifest.actions) == 2
56
+ assert len(manifest.navigation) == 1
57
+
58
+
59
+ def test_missing_api_key_raises_authentication_error(monkeypatch):
60
+ monkeypatch.delenv("MANIFEST_API_KEY", raising=False)
61
+ with pytest.raises(AuthenticationError):
62
+ ManifestClient()
63
+
64
+
65
+ @respx.mock
66
+ def test_401_raises_authentication_error():
67
+ respx.post(f"{BASE}/manifest").mock(return_value=httpx.Response(401))
68
+
69
+ client = ManifestClient(api_key="bad-key")
70
+ with pytest.raises(AuthenticationError):
71
+ client.get("https://example.com")
72
+
73
+
74
+ @respx.mock
75
+ def test_429_raises_rate_limit_error():
76
+ respx.post(f"{BASE}/manifest").mock(return_value=httpx.Response(429))
77
+
78
+ client = ManifestClient(api_key="test-key")
79
+ with pytest.raises(RateLimitError):
80
+ client.get("https://example.com")
81
+
82
+
83
+ @respx.mock
84
+ def test_500_raises_api_error():
85
+ respx.post(f"{BASE}/manifest").mock(return_value=httpx.Response(500, text="oops"))
86
+
87
+ client = ManifestClient(api_key="test-key")
88
+ with pytest.raises(APIError) as exc_info:
89
+ client.get("https://example.com")
90
+ assert exc_info.value.status_code == 500
91
+
92
+
93
+ @respx.mock
94
+ def test_action_lookup():
95
+ respx.post(f"{BASE}/manifest").mock(return_value=httpx.Response(200, json=MANIFEST_PAYLOAD))
96
+
97
+ client = ManifestClient(api_key="test-key")
98
+ manifest = client.get("https://example.com")
99
+
100
+ action = manifest.action("submit-form")
101
+ assert action is not None
102
+ assert action.label == "Submit"
103
+ assert manifest.action("nonexistent") is None
104
+
105
+
106
+ @respx.mock
107
+ def test_required_actions_filter():
108
+ respx.post(f"{BASE}/manifest").mock(return_value=httpx.Response(200, json=MANIFEST_PAYLOAD))
109
+
110
+ client = ManifestClient(api_key="test-key")
111
+ manifest = client.get("https://example.com")
112
+
113
+ required = manifest.required_actions
114
+ assert len(required) == 1
115
+ assert required[0].id == "email-input"
116
+
117
+
118
+ @respx.mock
119
+ def test_actions_of_type():
120
+ respx.post(f"{BASE}/manifest").mock(return_value=httpx.Response(200, json=MANIFEST_PAYLOAD))
121
+
122
+ client = ManifestClient(api_key="test-key")
123
+ manifest = client.get("https://example.com")
124
+
125
+ buttons = manifest.actions_of_type("button")
126
+ assert len(buttons) == 1
127
+ assert buttons[0].id == "submit-form"
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Async client
132
+ # ---------------------------------------------------------------------------
133
+
134
+ @respx.mock
135
+ async def test_async_client_happy_path():
136
+ respx.post(f"{BASE}/manifest").mock(return_value=httpx.Response(200, json=MANIFEST_PAYLOAD))
137
+
138
+ async with AsyncManifestClient(api_key="test-key") as client:
139
+ manifest = await client.get("https://example.com")
140
+
141
+ assert manifest.url == "https://example.com"
142
+ assert len(manifest.actions) == 2
143
+ assert manifest.action("submit-form") is not None
144
+ assert manifest.required_actions[0].id == "email-input"