Metadata-Version: 2.4
Name: tallyfy
Version: 3.0.3
Summary: A comprehensive Python SDK for interacting with the Tallyfy API
Author-email: Tallyfy <support@tallyfy.com>
Maintainer-email: Tallyfy <support@tallyfy.com>
License: MIT
Project-URL: Homepage, https://github.com/tallyfy/sdk
Project-URL: Documentation, https://tallyfy.com/products/
Project-URL: Repository, https://github.com/tallyfy/sdk
Project-URL: Bug Tracker, https://github.com/tallyfy/sdk/issues
Project-URL: Changelog, https://github.com/tallyfy/sdk/blob/main/CHANGELOG.md
Keywords: tallyfy,api,sdk,workflow,automation,task,management
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Office/Business :: Scheduling
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.25.0
Requires-Dist: email-validator==2.2.0
Provides-Extra: dev
Requires-Dist: pytest>=6.0.0; extra == "dev"
Requires-Dist: pytest-cov>=2.10.0; extra == "dev"
Requires-Dist: black>=21.0.1; extra == "dev"
Requires-Dist: flake8>=3.8.0; extra == "dev"
Requires-Dist: mypy>=0.800; extra == "dev"
Requires-Dist: responses>=0.20.0; extra == "dev"
Requires-Dist: dnspython>=2.0.0; extra == "dev"
Requires-Dist: tomli>=1.1.0; extra == "dev"
Requires-Dist: build>=1.0.0; extra == "dev"
Dynamic: license-file

# Tallyfy SDK

A comprehensive Python SDK for interacting with the Tallyfy API. This SDK provides a clean, modular interface for managing users, tasks, templates, and form fields in your Tallyfy organization.

## Table of Contents

- [Installation](#installation)
- [Quick Start](#quick-start)
- [Architecture](#architecture)
- [Core Features](#core-features)
- [API Reference](#api-reference)
- [Data Models](#data-models)
- [Error Handling](#error-handling)
- [Examples](#examples)
- [Advanced Usage](#advanced-usage)
- [Testing](#testing)
- [Contributing](#contributing)

## Installation

```bash
pip install tallyfy
```

**Dependencies:**
- `requests` - HTTP client library
- `typing` - Type hints support (Python 3.5+)

## Quick Start

```python
from tallyfy import TallyfySDK

# Initialize the SDK
sdk = TallyfySDK(
    api_key="your_api_key_here"
)

# Get organization users
users = sdk.users.get_organization_users(org_id="your_org_id")

# Get current user's tasks
my_tasks = sdk.tasks.get_my_tasks(org_id="your_org_id")

# Search for templates
templates = sdk.search(org_id="your_org_id", search_query="onboarding", search_type="blueprint")

# Get a specific template
template = sdk.templates.get_template(org_id="your_org_id", template_name="Employee Onboarding")
```

## Architecture

The Tallyfy SDK follows a modular architecture with specialized management classes and comprehensive data models:

### Core Components

- **`TallyfySDK`** - Main SDK class that orchestrates all operations with backward compatibility methods
- **`BaseSDK`** - Base class with HTTP request handling, retry logic, and connection pooling
- **Management Modules:**
  - `UserManager` - User and guest operations with modular retrieval and invitation components
  - `TaskManager` - Task and process operations with search and creation capabilities
  - `TemplateManager` - Template/checklist operations with automation analysis and health assessment
  - `FormFieldManager` - Form field operations with AI-powered suggestions and dropdown management
- **Error Handling:**
  - `TallyfyError` - Custom exception handling with status codes and response data

### Response models keep the fields they do not recognise

Every model is a dataclass with a `from_dict` that maps a fixed list of keys.
Anything else the API returned lands in `extra`, a plain dict on the model, so a
field the SDK has not caught up with yet is still reachable instead of being
thrown away:

```python
task = sdk.get_task(org_id, run_id, task_id)

task.title              # a mapped field, exactly as before
task.extra              # {'ai_session_id': 'sess-9f21', ...} anything unmapped
task.extra.get('shown_at')
```

`extra` is empty when the model covered the whole response, which is the normal
case. Reads work this way on purpose: a key the SDK does not recognise on a read
is API surface that already exists on the server, so dropping it is the loss.
Writes take the opposite line and reject fields they do not recognise, because
there a stray key means the write silently stores nothing.

If you find something useful in `extra`, please open an issue so it can be
promoted to a real field.

### File Structure

```
tallyfy/
├── __init__.py                        # SDK exports and version
├── core.py                            # BaseSDK and TallyfySDK classes
├── models.py                          # Data models and types
├── user_management/                   # User and guest management (modular)
│   ├── __init__.py                    # UserManager with unified interface
│   ├── base.py                        # Common validation and error handling
│   ├── retrieval.py                   # User and guest retrieval operations
│   └── invitation.py                  # User invitation operations
├── task_management/                   # Task and process management (modular)
│   ├── __init__.py                    # TaskManager with unified interface
│   ├── base.py                        # Common task operations base
│   ├── retrieval.py                   # Task and process retrieval
│   ├── search.py                      # Search functionality
│   └── creation.py                    # Task creation operations
├── template_management/               # Template management (modular)
│   ├── __init__.py                    # TemplateManager with unified interface
│   ├── base.py                        # Common template operations
│   ├── basic_operations.py            # Template retrieval and CRUD operations
│   ├── automation.py                  # Automation rule management
│   └── analysis.py                    # Step dependency and visibility analysis
├── form_fields_management/            # Form field management (modular)
│   ├── __init__.py                    # FormFieldManager with unified interface
│   ├── base.py                        # Common form field operations
│   ├── crud_operations.py             # CRUD operations for form fields
│   ├── options_management.py          # Dropdown options management
│   └── suggestions.py                 # AI-powered field suggestions
└── README.md                          # This documentation
```

## Core Features

### 🔐 Authentication & Security
- Bearer token authentication with session management
- Configurable request timeouts and connection pooling
- Automatic retry logic for transient failures (5xx errors) and for rate limits (429)
- No retry for any other client error (4xx) to prevent API abuse
- Comprehensive error handling with detailed error information

### 👥 User Management
- **Modular architecture** with specialized retrieval and invitation components
- Get organization members with full profile data (country, timezone, job details)
- Get minimal user lists for performance-critical operations
- Manage guests and external users with guest-specific features
- **Enhanced search capabilities** - Find users by email or name with fuzzy matching
- **Batch invitation support** - Invite multiple users with default roles and messages
- **Advanced invitation features** - Custom messages, role validation, and resend functionality
- Support for user groups and permissions with flexible query parameters
- **Convenience methods** - Get all members (users + guests) in a single call

### ✅ Task Management  
- Get tasks for specific users or processes with filtering
- Create standalone tasks with rich assignment options
- Search processes by name with fuzzy matching
- Advanced filtering for organization runs (status, owners, tags, etc.)
- Universal search across processes, templates, and tasks

### 📋 Template Management
- Get templates with full metadata and step details
- Search templates by name with exact and fuzzy matching
- Update template properties and metadata
- Duplicate templates with permission copying options
- **Automation management** - Create, update, and analyze conditional rules
- **Step dependency analysis** - Understand step visibility conditions
- **AI-powered deadline suggestions** for individual steps
- **Template health assessment** - Comprehensive analysis of template quality
- **Automation consolidation** - Optimize and merge redundant rules
- Add assignees and edit step descriptions
- Kickoff field management for template launch forms

### 📝 Form Field Management
- Add form fields to template steps with comprehensive validation
- Support for text, dropdown, date, file upload, WYSIWYG editor fields
- Update field properties, validation rules, and positioning
- Move fields between steps with automatic reordering
- **AI-powered field suggestions** based on step analysis
- Manage dropdown options with bulk updates
- **Smart field recommendations** with confidence scoring
- Field dependency management and conditional logic

### 🔍 Search & Discovery
- Universal search across processes, templates, and tasks
- Exact and fuzzy matching with relevance scoring
- Pagination support with configurable page sizes
- Rich search results with metadata and context
- Process and template name-based search with suggestions

## API Reference

### SDK Initialization

```python
TallyfySDK(
    api_key: str,
    timeout: int = 30,
    max_retries: int = 3,
    retry_delay: float = 1.0
)
```

### User Management

```python
# Get all organization users with optional group data
users = sdk.users.get_organization_users(org_id, with_groups=False)

# Get minimal user list for performance
users_list = sdk.users.get_organization_users_list(org_id)

# Get organization guests with optional statistics
guests = sdk.users.get_organization_guests(org_id, with_stats=False)

# Get current user info
current_user = sdk.users.get_current_user_info(org_id)

# Enhanced search capabilities
user = sdk.users.get_user_by_email(org_id, "john@company.com")
guest = sdk.users.get_guest_by_email(org_id, "contractor@company.com")

# Search members by name (fuzzy matching)
search_results = sdk.users.search_members_by_name(org_id, "John Smith", include_guests=True)
matching_users = search_results['users']
matching_guests = search_results['guests']

# Get all members in one call
all_members = sdk.users.get_all_organization_members(
    org_id, include_guests=True, with_groups=True, with_stats=True
)

# Invite single user to organization
user = sdk.users.invite_user_to_organization(
    org_id, email="new.hire@company.com", 
    first_name="John", last_name="Doe",
    role="standard", 
    message="Welcome! Please complete your onboarding process."
)

# Batch invite multiple users
invitations = [
    {
        "email": "user1@company.com",
        "first_name": "Jane",
        "last_name": "Smith",
        "role": "standard"
    },
    {
        "email": "user2@company.com", 
        "first_name": "Bob",
        "last_name": "Johnson",
        "message": "Welcome to the engineering team!"
    }
]
results = sdk.users.invite_multiple_users(
    org_id, invitations, 
    default_role="light",
    default_message="Welcome to our organization!"
)

# Resend invitation
success = sdk.users.resend_invitation(
    org_id, email="pending@company.com",
    message="Reminder: Please accept your invitation to join our organization."
)

# Generate custom invitation message
message = sdk.users.get_invitation_template_message(
    org_name="ACME Corp",
    custom_text="We're excited to have you join our innovative team!"
)

# Validate invitation data before sending
invitation_data = {
    "email": "new@company.com",
    "first_name": "Alice",
    "last_name": "Wilson",
    "role": "admin"
}
validated_data = sdk.users.validate_invitation_data(invitation_data)

# Access modular components directly (advanced usage)
# Retrieval operations
users_with_groups = sdk.users.retrieval.get_organization_users(org_id, with_groups=True)

# Invitation operations  
batch_results = sdk.users.invitation.invite_multiple_users(org_id, invitations)
```

### Task Management

```python
# Get current user's tasks
my_tasks = sdk.tasks.get_my_tasks(org_id)

# Get specific user's tasks
user_tasks = sdk.tasks.get_user_tasks(org_id, user_id)

# Get tasks for a process
process_tasks = sdk.tasks.get_tasks_for_process(org_id, process_id=None, process_name="My Process")

# Get organization processes with filtering
runs = sdk.tasks.get_organization_runs(
    org_id, 
    status="active",
    owners="123,456", 
    checklist_id="template_id"
)

# Create standalone task
task = sdk.tasks.create_task(
    org_id, title="Review Document", 
    deadline="2024-12-31T23:59:59Z",
    description="Please review the attached document"
)

# Search processes, templates, or tasks
results = sdk.tasks.search(org_id, "onboarding", search_type="process")

# Search for specific entity types
templates = sdk.tasks.search(org_id, "employee onboarding", search_type="blueprint")
tasks = sdk.tasks.search(org_id, "review", search_type="task")
```

### Template Management

```python
# Get template by ID or name
template = sdk.templates.get_template(org_id, template_id=None, template_name="Onboarding")

# Get template with full step details
template_data = sdk.templates.get_template_with_steps(org_id, template_id)

# Update template metadata
updated = sdk.templates.update_template_metadata(
    org_id, template_id,
    title="New Title",
    summary="Updated summary",
    guidance="New guidance text"
)

# Get template steps
steps = sdk.templates.get_template_steps(org_id, template_id)

# Analyze step dependencies  
dependencies = sdk.templates.get_step_dependencies(org_id, template_id, step_id)

# Advanced template operations
duplicated = sdk.templates.duplicate_template(org_id, template_id, "New Template Name", copy_permissions=True)

# Automation management
automation_data = {
    "automated_alias": "Auto-assign reviewer",
    "conditions": [{"field_id": "department", "operator": "equals", "value": "Engineering"}],
    "actions": [{"type": "assign_step", "step_id": "review_step", "assignee_type": "user", "assignee_id": "123"}]
}
automation = sdk.templates.create_automation_rule(org_id, template_id, automation_data)

# Template metadata management
updated = sdk.templates.update_template_metadata(
    org_id, template_id,
    title="Updated Template Title",
    guidance="New guidance text",
    is_featured=True
)

# Add assignees to step
assignees = {"users": [123, 456], "groups": ["managers"], "guests": ["contractor@company.com"]}
sdk.templates.add_assignees_to_step(org_id, template_id, step_id, assignees)

# Edit step description
sdk.templates.edit_description_on_step(org_id, template_id, step_id, "Updated step description with detailed instructions")

# Add new step to template
step_data = {
    "title": "Quality Review",
    "description": "Perform final quality check",
    "position": 5,
    "assignees": {"users": [789]}
}
new_step = sdk.templates.add_step_to_template(org_id, template_id, step_data)
```

### Form Field Management

```python
# Add form field to step
field_data = {
    "field_type": "text",
    "label": "Customer Name", 
    "required": True,
    "position": 1
}
field = sdk.form_fields.add_form_field_to_step(org_id, template_id, step_id, field_data)

# Update form field
updated_field = sdk.form_fields.update_form_field(
    org_id, template_id, step_id, field_id,
    label="Updated Label",
    required=False
)

# Move field between steps
success = sdk.form_fields.move_form_field(
    org_id, template_id, from_step, field_id, to_step, position=2
)

# Delete form field
success = sdk.form_fields.delete_form_field(org_id, template_id, step_id, field_id)

# Get dropdown options
options = sdk.form_fields.get_dropdown_options(org_id, template_id, step_id, field_id)

# Update dropdown options
success = sdk.form_fields.update_dropdown_options(
    org_id, template_id, step_id, field_id, 
    ["Option 1", "Option 2", "Option 3"]
)
```

## Data Models

The SDK provides comprehensive dataclasses for type safety and easy data access:

### Core Models

- **`User`** - Organization member with full profile data (country, timezone, job details)
- **`Guest`** - External user with limited access and guest-specific details
- **`GuestDetails`** - Extended guest information and statistics
- **`Task`** - Individual work item with owners, deadlines, and process linkage
- **`Run`** - Process instance (workflow execution) with progress tracking
- **`Template`** - Complete template/checklist definition with automation rules
- **`Step`** - Individual step within a template with conditions and assignments

### Assignment and Ownership Models

- **`TaskOwners`** - Task assignment information supporting users, guests, and groups
- **`RunProgress`** - Process completion tracking with step-level progress
- **`Country`** - Geographic data for user profiles
- **`Folder`** - Organizational folder structure for templates and processes

### Template and Automation Models

- **`Tag`** - Industry and topic classification tags
- **`PrerunField`** - Form fields for template kickoff with validation rules
- **`AutomationCondition`** - Conditional logic for workflow automation
- **`AutomationAction`** - Actions triggered by automation rules
- **`AutomatedAction`** - Complete automation rule with conditions and actions
- **`AutomationDeadline`** - Deadline configuration for automated actions
- **`AutomationAssignees`** - Assignee configuration for automated actions

### Step and Form Models

- **`Capture`** - Form fields within steps with validation and positioning
- **`StepStartDate`** - Start date configuration for steps
- **`StepDeadline`** - Deadline configuration for individual steps
- **`StepBpToLaunch`** - Sub-process launch configuration

### Search and Utility Models

- **`SearchResult`** - Unified search results for templates, processes, and tasks
- **`TallyfyError`** - Custom exception with status codes and response data

### Model Features

- **Automatic parsing** from API responses via `from_dict()` class methods
- **Type safety** with comprehensive type hints
- **Nested object support** for complex data structures
- **Default value handling** for optional fields

Example model usage:

```python
# Models automatically parse API responses
users = sdk.users.get_organization_users(org_id)
for user in users:
    print(f"{user.full_name} ({user.email})")
    if user.country:
        print(f"Country: {user.country.name}")

# Access nested data safely
template = sdk.templates.get_template(org_id, template_name="Onboarding")
if template.automated_actions:
    for automation in template.automated_actions:
        print(f"Automation: {automation.automated_alias}")
        for condition in automation.conditions:
            print(f"  Condition: {condition.statement}")
```

## Error Handling

The SDK provides comprehensive error handling through the `TallyfyError` class:

```python
from tallyfy import TallyfyError

try:
    users = sdk.users.get_organization_users("invalid_org_id")
except TallyfyError as e:
    print(f"API Error: {e}")
    print(f"Status Code: {e.status_code}")
    print(f"Response Data: {e.response_data}")
except ValueError as e:
    print(f"Validation Error: {e}")
```

### Error Types

- **`TallyfyError`** - API-specific errors with status codes and response data
- **`ValueError`** - Input validation errors for required parameters
- **`RequestException`** - Network and connection errors (automatically retried)

### Retry Logic

The SDK automatically retries failed requests with configurable settings:

- **Server errors (5xx)** - 500, 502, 503 and 504 are retried up to `max_retries` times, on
  idempotent methods only. A 5xx on a POST or PATCH raises `TallyfyIndeterminateError` after one
  attempt, because the request reached a server that then failed and may already have been applied.
- **Rate limits (429)** - Retried up to `max_retries` times on **every** method, POST and PATCH
  included. A 429 is the server declining to process the request, so nothing was applied and a
  retry cannot duplicate a write. The response's `Retry-After` header is obeyed (both delta-seconds
  and HTTP-date forms), clamped to `BaseSDK.MAX_RETRY_DELAY` (120s) so a bad header cannot park the
  caller. With no usable header it falls back to the exponential backoff below. When the budget is
  exhausted it raises a plain `TallyfyError` with `status_code=429` and `side_effect_possible=False`.
- **Other client errors (4xx)** - Not retried (indicates user/input error)
- **Network errors** - Retried with exponential backoff
- **Timeout errors** - Retried with configurable delay

Set `max_retries=0` to opt out of all of the above, including the 429 handling, if you are running
your own rate limiter and do not want the SDK sleeping inside the call.

## Examples

### Complete User Onboarding Workflow

```python
from tallyfy import TallyfySDK, TaskOwners

sdk = TallyfySDK(api_key="your_api_key")
org_id = "your_org_id"

# 1. Invite new user
new_user = sdk.users.invite_user_to_organization(
    org_id, 
    email="new.hire@company.com",
    first_name="John", 
    last_name="Doe",
    role="standard",
    message="Welcome to the team! Please complete your onboarding."
)

# 2. Create onboarding task
if new_user:
    owners = TaskOwners(users=[new_user.id])
    onboarding_task = sdk.tasks.create_task(
        org_id,
        title="Complete Employee Onboarding",
        deadline="2025-12-31 17:00:00",
        description="Please complete all onboarding steps including HR paperwork and IT setup.",
        owners=owners
    )
    print(f"Created onboarding task: {onboarding_task.id}")
```

### Template Analysis and Enhancement

```python
# Get template with full details
template_data = sdk.templates.get_template_with_steps(org_id, template_name="Project Kickoff")

if template_data:
    template = template_data['template']
    print(f"Template: {template.title}")
    print(f"Steps: {template_data['step_count']}")
    print(f"Automations: {template_data['automation_count']}")
    
    # Analyze each step for improvements
    for step_data in template_data['steps']:
        step_id = step_data['id']
        
        # Get dependency analysis
        dependencies = sdk.templates.get_step_dependencies(org_id, template.id, step_id)
        if dependencies['has_conditional_visibility']:
            print(f"Step '{step_data['title']}' has conditional logic")
```

### Advanced Process Management

```python
# Get all active processes with comprehensive filtering
active_runs = sdk.tasks.get_organization_runs(
    org_id,
    status="active",
    with_data="checklist,tasks,assets,tags",
    form_fields_values=True,
    starred=True
)

# Group by template
template_usage = {}
for run in active_runs:
    template_id = run.checklist_id
    if template_id not in template_usage:
        template_usage[template_id] = {
            'template_name': run.checklist_title,
            'active_count': 0,
            'runs': []
        }
    template_usage[template_id]['active_count'] += 1
    template_usage[template_id]['runs'].append(run)

# Show most used templates
sorted_templates = sorted(template_usage.items(), key=lambda x: x[1]['active_count'], reverse=True)
for template_id, data in sorted_templates[:5]:
    print(f"{data['template_name']}: {data['active_count']} active processes")
```

## Advanced Usage

### Custom Request Configuration

```python
# SDK with custom configuration
sdk = TallyfySDK(
    api_key="your_api_key",
    base_url="https://api.tallyfy.com",
    timeout=60,  # 60 second timeout
    max_retries=5,  # Retry up to 5 times
    retry_delay=2.0  # 2 second delay between retries
)
```

### Accessing Raw API Responses

```python
# For advanced users who need raw API data
template_data = sdk.templates.get_template_with_steps(org_id, template_id)
raw_api_response = template_data['raw_data']

# Access nested API data not exposed in models
custom_fields = raw_api_response.get('custom_metadata', {})
```

### Batch Operations

```python
# Efficiently process multiple operations
org_users = sdk.users.get_organization_users(org_id)
user_tasks = {}

for user in org_users[:10]:  # Process first 10 users
    try:
        tasks = sdk.tasks.get_user_tasks(org_id, user.id)
        user_tasks[user.id] = tasks
        print(f"{user.full_name}: {len(tasks)} tasks")
    except TallyfyError as e:
        print(f"Failed to get tasks for {user.full_name}: {e}")
```


## Testing

```bash
pip install -e ".[dev]"   # or: make dev-install
pytest tests/             # or: make test
```

Useful selections:

```bash
pytest tests/test_core_request.py -v      # just the HTTP retry loop
pytest -m characterization                # only the tests that pin open defects
pytest -m "not characterization"          # only behaviour that is correct today
pytest --cov=tallyfy --cov-report=term-missing
```

### How these tests are built

The HTTP transport is faked with [`responses`](https://github.com/getsentry/responses), so
`BaseSDK._make_request` runs **unmodified** — its retry loop, status handling and JSON parsing
all execute for real. `responses.calls` then gives an exact count of the requests that went on
the wire, which is the quantity most of the interesting assertions are about.

Do not write tests that replace `TallyfySDK` or `_make_request` with a mock. A test built that
way cannot fail when the code it is supposed to guard breaks.

`tests/conftest.py` provides:

| Fixture | What it gives you |
|---|---|
| `sdk` | a `TallyfySDK` on an unroutable fake base URL, with the shipped retry defaults |
| `mocked_responses` | autouse `responses.RequestsMock`; register responses and read `.calls` |
| `sleep_calls` | autouse no-op `time.sleep` that **records** durations, so retries are instant and the backoff is assertable |
| `api_url` | builds a full URL against the fake base |

### The `characterization` marker

Tests marked `@pytest.mark.characterization` pin behaviour that is **wrong today**, so each
open defect has a landing site. Every one names its issue in its docstring. Whoever fixes that
issue inverts the assertion and drops the marker in the same commit.

**A characterization test going red is a signal to read its docstring, not a regression.**

### Before you trust a new test

Revert the fix it is supposed to guard and confirm that *exactly* the intended assertions go
red while their neighbours stay green. A run in which everything fails, or in which the wrong
things fail, means the test is measuring something other than what you think.

### CI

`.github/workflows/tests.yml` runs the suite on every pull request into `main`, on every push
to `main`, and — via `workflow_call` — as a required job inside both publish paths. Neither
`build-whl.yml` nor `release.yml` can publish if the suite fails.

`mypy` is deliberately **not** in the gate. `[tool.mypy]` in `pyproject.toml` is configured
strictly and does not pass against the current codebase; adopting it is separate work.

#### Some of that CI runs on our own machines, not GitHub's

This repo has a repository variable `CI_LINUX_RUNNER` set to `linux-ci`, and `tests.yml` reads it
in a `runs-on:` expression, so those jobs run on hardware we own and cost nothing. When the
variable is unset they go back to GitHub-hosted runners and bill normally. **Deleting the variable
is the whole rollback**, and it needs no change to any workflow.

Two things differ from a GitHub-hosted runner, and they matter to a Python package that ships
wheels.

**The processor architecture is not guaranteed.** Five machines carry the `linux-ci` label: two
are ARM64 and three are X64. A job lands on whichever is free. Nothing tells you which one you
will get in advance, though the run records it afterwards, on line 2 of the job log and in the
`runner_name` field of `gh api repos/tallyfy/sdk/actions/jobs/{job_id}`. Anything with a compiled
wheel can therefore pass one day and fail the next with nothing in the diff to explain it. If a
job here needs one architecture, say so in the workflow.

✅ **SUPERSEDED 2026-09-04: `linux-ci` IS SINGLE-ARCHITECTURE X64, WHICH MATTERS MORE HERE THAN IN
MOST REPOS BECAUSE THIS PACKAGE SHIPS WHEELS.** The two ARM64 machines were Docker containers on the
operator's Mac; they were deregistered from the org at 18:40Z and their LaunchAgents booted out AND
disabled, so they do not return on reboot. Three slots carry the label and all three are X64 native
services on DigitalOcean droplets. The paragraph above is left exactly as written because it was
correct for its dates and its reasoning applies again the moment a second architecture is added.
Derive it, never quote it: `gh api 'orgs/tallyfy/actions/runners?per_page=100'`.

🔴 **AND THE SLOTS ARE FAR SMALLER THAN `ubuntu-latest`, WHICH IS THE FACT THAT DECIDES WHETHER A
JOB CAN RUN HERE AT ALL.** Measured 2026-09-04 by `systemctl show` on both droplets, twice, from two
sessions independently:

| slot | MemoryMax | cores |
|---|---|---|
| `answers-n8n-answers-heavy-1` and `-2` | 2306867200, so 2.15 GiB each | one each, `AllowedCPUs` 2 and 3 |
| `tallyfy-mcp-mcp-small-1` | 1048576000, so 1.0 GiB | one, `AllowedCPUs` 1 |
| GitHub `ubuntu-latest`, for comparison | 16 GB | four |

The HOSTS are bigger than the slots (`answers-n8n` 4 cores and 8.33 GB, `tallyfy-mcp` 2 cores and
4.11 GB); the cgroup caps above are what a job actually gets. Building a wheel with a compiled
extension in 1.0 GiB and one core is the thing to check before assuming a job fits:
`systemctl show -p MemoryMax -p AllowedCPUs actions.runner.tallyfy.<slot>.service`.

**The machines carry far less software than the `ubuntu-latest` image.** `python3`, `git`, `curl`,
`jq`, `shellcheck` and `gh` are present. Assume anything else is absent until a job proves
otherwise, and when something is missing the fix belongs in the runner setup rather than in the
workflow.

This section lives in the README rather than in a `CLAUDE.md`, because `.gitignore` excludes that
filename in this repo. Full detail, the slot inventory, the caps and the rollback are in the
private `tallyfy/systems` repo at `docs/ci-runners.md`.

## Contributing

### Code Style

- Follow PEP 8 style guidelines
- Use type hints for all functions and methods
- Add comprehensive docstrings for public APIs
- Include error handling for all external API calls

### Adding New Features

1. Add methods to appropriate management class
2. Create or update data models in `models.py`
3. Add comprehensive docstrings and type hints
4. Include error handling and logging
5. Update this README with usage examples


## Support

For bugs, feature requests, or questions:

1. Check existing issues in the project repository
2. Contact us at: support@tallyfy.com
---

**Version:** 1.0.14
**Last Updated:** 2025
